Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
521 changes: 276 additions & 245 deletions pipelines/weekly-summary-standalone.yml

Large diffs are not rendered by default.

60 changes: 33 additions & 27 deletions pipelines/weekly-summary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# SMTP_FROM timetracker@yourcompany.com
# SMTP_PORT 25 (optional)
# SMTP_SECURE false (optional, true for TLS)
# SMTP_IGNORE_TLS true (optional, skip STARTTLS — use when server cert is expired)
# SMTP_USER (optional, for SMTP auth)
# SMTP_PASS (optional) [SECRET]
#
Expand Down Expand Up @@ -41,32 +42,37 @@ schedules:

trigger: none

pool:
name: Default # your self-hosted agent pool
jobs:
- job: SendWeeklySummary
timeoutInMinutes: 70
pool:
name: Default
demands:
- Agent.OS -equals Windows_NT
steps:
- task: NodeTool@0
displayName: Use Node.js 16
inputs:
versionSpec: '16.x'

steps:
- task: NodeTool@0
displayName: Use Node.js 16
inputs:
versionSpec: '16.x'
- script: npm install --production
displayName: Install script dependencies
workingDirectory: $(Build.SourcesDirectory)/scripts

- script: npm install --production
displayName: Install script dependencies
workingDirectory: $(Build.SourcesDirectory)/scripts

- script: node send-weekly-summary.js
displayName: Send weekly hour summary emails
workingDirectory: $(Build.SourcesDirectory)/scripts
env:
AZDO_SERVER_URL: $(AZDO_SERVER_URL)
AZDO_PAT: $(AZDO_PAT)
AZDO_PUBLISHER: $(AZDO_PUBLISHER)
AZDO_EXTENSION_ID: $(AZDO_EXTENSION_ID)
SMTP_HOST: $(SMTP_HOST)
SMTP_PORT: $(SMTP_PORT)
SMTP_FROM: $(SMTP_FROM)
SMTP_SECURE: $(SMTP_SECURE)
SMTP_USER: $(SMTP_USER)
SMTP_PASS: $(SMTP_PASS)
OVERRIDE_WEEK_START: '${{ parameters.override_week_start }}'
DRY_RUN: ${{ parameters.dry_run }}
- script: node send-weekly-summary.js
displayName: Send weekly hour summary emails
workingDirectory: $(Build.SourcesDirectory)/scripts
env:
AZDO_SERVER_URL: $(AZDO_SERVER_URL)
AZDO_PAT: $(AZDO_PAT)
AZDO_PUBLISHER: $(AZDO_PUBLISHER)
AZDO_EXTENSION_ID: $(AZDO_EXTENSION_ID)
SMTP_HOST: $(SMTP_HOST)
SMTP_PORT: $(SMTP_PORT)
SMTP_FROM: $(SMTP_FROM)
SMTP_SECURE: $(SMTP_SECURE)
SMTP_IGNORE_TLS: $(SMTP_IGNORE_TLS)
SMTP_USER: $(SMTP_USER)
SMTP_PASS: $(SMTP_PASS)
OVERRIDE_WEEK_START: '${{ parameters.override_week_start }}'
DRY_RUN: ${{ parameters.dry_run }}
105 changes: 73 additions & 32 deletions scripts/send-weekly-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const SMTP_HOST = process.env.SMTP_HOST || '';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '25', 10);
const SMTP_FROM = process.env.SMTP_FROM || '';
const SMTP_SECURE = process.env.SMTP_SECURE === 'true';
const SMTP_IGNORE_TLS = process.env.SMTP_IGNORE_TLS === 'true';
const SMTP_USER = process.env.SMTP_USER || null;
const SMTP_PASS = process.env.SMTP_PASS || null;
const DRY_RUN = process.env.DRY_RUN === 'true';
Expand Down Expand Up @@ -165,8 +166,29 @@ async function fetchDocument(collectionName, debug) {

const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

function buildTotalCell(total, target) {
const { color, label } = targetStatus(total, target);
const totalStr = total.toFixed(1);
if (!target || target <= 0) {
return `<span style="font-weight:600;font-size:16px;color:${color}">${totalStr}</span>`;
}
const pct = Math.round((total / target) * 100);
return `<span style="font-weight:600;font-size:16px;color:${color}">${totalStr}&thinsp;/&thinsp;${target}</span>` +
`<br><span style="font-size:11px;color:${color}">${pct}% &mdash; ${label}</span>`;
}

function targetStatus(total, target) {
if (!target || target <= 0) return { color: '#0078d4', label: null };
const pct = total / target;
if (pct < 0.50) return { color: '#d13438', label: 'Poor' };
if (pct < 0.75) return { color: '#ca5010', label: 'Medium' };
if (pct < 1.00) return { color: '#986f0b', label: 'OK (not Good)' };
return { color: '#107c10', label: 'Good' };
}

function buildEmailHtml(user, days, startStr, endStr) {
const total = days.reduce((sum, d) => sum + d.hours, 0);
const total = days.reduce((sum, d) => sum + d.hours, 0);
const target = user.targetHours || 0;

const rows = days.map(d => {
const bg = d.hours === 0 ? '#fff4ce' : '#ffffff';
Expand Down Expand Up @@ -210,7 +232,7 @@ function buildEmailHtml(user, days, startStr, endStr) {
<tfoot>
<tr style="background:#f3f2f1">
<td colspan="2" style="padding:9px 14px;font-weight:600">Total</td>
<td style="padding:9px 14px;text-align:right;font-weight:600;font-size:16px;color:#0078d4">${total.toFixed(1)}</td>
<td style="padding:9px 14px;text-align:right">${buildTotalCell(total, target)}</td>
</tr>
</tfoot>
</table>
Expand Down Expand Up @@ -321,6 +343,7 @@ async function main() {
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
ignoreTLS: SMTP_IGNORE_TLS,
family: 4,
connectionTimeout: 60000,
greetingTimeout: 60000,
Expand All @@ -329,12 +352,9 @@ async function main() {
if (SMTP_USER) transportOptions.auth = { user: SMTP_USER, pass: SMTP_PASS };
const transporter = nodemailer.createTransport(transportOptions);

let sent = 0, failed = 0;

for (const user of enabledUsers) {
// Pre-build all mail objects so the retry loop only deals with transport errors.
const mails = enabledUsers.map(user => {
const entries = byUser[user.userId] || [];

// Build Monday–Sunday day breakdown
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
Expand All @@ -344,44 +364,65 @@ async function main() {
const hours = dayEntries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
days.push({ dayName: DAY_NAMES[d.getDay()], date: dateStr, hours });
}

const total = days.reduce((s, d) => s + d.hours, 0);
const total = days.reduce((s, d) => s + d.hours, 0);
const ccList = (user.cc || '').split(',').map(s => s.trim()).filter(Boolean);
return {
user, total,
mailOptions: {
from: SMTP_FROM,
to: user.userEmail,
cc: ccList.length ? ccList.join(', ') : undefined,
subject: `Weekly Hours Summary: ${startStr} – ${endStr}`,
html: buildEmailHtml(user, days, startStr, endStr),
}
};
});

const subject = `Weekly Hours Summary: ${startStr} – ${endStr}`;
const html = buildEmailHtml(user, days, startStr, endStr);

if (DRY_RUN) {
console.log(`[DRY RUN] → ${user.userEmail}${ccList.length ? ' CC: ' + ccList.join(', ') : ''} | Total: ${total.toFixed(1)} h`);
days.forEach(d => console.log(` ${d.dayName.padEnd(10)} ${d.date} ${d.hours.toFixed(1)} h`));
sent++;
continue;
if (DRY_RUN) {
for (const { user, total, mailOptions } of mails) {
console.log(`[DRY RUN] → ${mailOptions.to}${mailOptions.cc ? ' CC: ' + mailOptions.cc : ''} | Total: ${total.toFixed(1)} h`);
}
console.log(`Done (dry run). Would send: ${mails.length}.`);
return;
}

try {
await transporter.sendMail({
from: SMTP_FROM,
to: user.userEmail,
cc: ccList.length ? ccList.join(', ') : undefined,
subject,
html,
});
console.log(`Sent → ${user.userEmail}${ccList.length ? ' CC: ' + ccList.join(', ') : ''}`);
sent++;
} catch (err) {
console.error(`Failed → ${user.userEmail}: ${err.message}`);
failed++;
const MAX_ROUNDS = 12;
const RETRY_DELAY = 5 * 60 * 1000; // 5 minutes
let sent = 0, failed = 0;
let pending = [...mails];

for (let round = 1; round <= MAX_ROUNDS && pending.length > 0; round++) {
if (round > 1) {
console.log(`Retrying ${pending.length} failed email(s) — round ${round}/${MAX_ROUNDS}, waiting 5 min…`);
await new Promise(r => setTimeout(r, RETRY_DELAY));
}
const stillFailed = [];
for (const item of pending) {
try {
await transporter.sendMail(item.mailOptions);
console.log(`Sent → ${item.mailOptions.to}${item.mailOptions.cc ? ' CC: ' + item.mailOptions.cc : ''}`);
sent++;
} catch (err) {
console.warn(`Round ${round} failed → ${item.mailOptions.to}: ${err.message}`);
stillFailed.push(item);
}
}
pending = stillFailed;
}

failed = pending.length;
for (const item of pending) {
console.error(`Giving up → ${item.mailOptions.to} after ${MAX_ROUNDS} rounds.`);
}

console.log(`Done. Sent: ${sent}, Failed: ${failed}.`);
if (failed > 0) process.exit(1);
}

const globalTimeout = setTimeout(() => {
console.error('Fatal: script timed out after 10 minutes. Check SMTP_HOST connectivity.');
console.error('Fatal: script timed out after 65 minutes.');
process.exit(1);
}, 600000);
}, 65 * 60 * 1000);
globalTimeout.unref();

main().catch(err => {
Expand Down
16 changes: 7 additions & 9 deletions src/my-time.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
html, body { height: 100vh; overflow: auto; }
html, body { height: 100vh; overflow: hidden; }
body { padding: 20px; display: flex; flex-direction: column; }
.page-scroll { flex: 1; min-height: 0; overflow-y: auto; }

.page-header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; flex-shrink: 0; }
.page-header h1 { margin: 0; font-weight: 600; }
Expand Down Expand Up @@ -90,14 +91,11 @@ body { padding: 20px; display: flex; flex-direction: column; }
.wi-title { font-weight: 600; }
.wi-id { color: var(--text-secondary); font-size: 12px; }

/* Inline quick-log */
.quicklog { display: flex; align-items: center; gap: 6px; }
.quicklog input[type="number"] { width: 64px; }
.quicklog input[type="date"] { width: 140px; }
.quicklog button { padding: 5px 12px; font-size: 13px; }

.delete-btn { background-color: var(--error-border) !important; padding: 4px 10px; font-size: 12px; }
.delete-btn:hover { opacity: 0.85; }
/* week navigation header */
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.section-header .section-title { margin: 0; }
.week-nav { display: flex; gap: 4px; }
.week-nav-btn { padding: 2px 10px; font-size: 16px; line-height: 1; }

.empty { padding: 32px; text-align: center; color: var(--text-secondary); }
.loading { padding: 40px; text-align: center; color: var(--text-secondary); }
Expand Down
Loading