diff --git a/pipelines/weekly-summary-standalone.yml b/pipelines/weekly-summary-standalone.yml index 75b1897..df8608d 100644 --- a/pipelines/weekly-summary-standalone.yml +++ b/pipelines/weekly-summary-standalone.yml @@ -1,4 +1,4 @@ -# Weekly Hour Summary Emails — standalone version +# Weekly Hour Summary Emails — standalone version # # Use this file if the extension repository is NOT hosted in the same Azure # DevOps instance where you want to run the pipeline. @@ -18,6 +18,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] # @@ -46,252 +47,282 @@ schedules: trigger: none -pool: - name: Default # your self-hosted agent pool - -steps: - - task: NodeTool@0 - displayName: Use Node.js 16 - inputs: - versionSpec: '16.x' - - - script: npm install nodemailer - displayName: Install nodemailer - - - task: PowerShell@2 - displayName: Write script to disk - inputs: - pwsh: false - targetType: inline - script: | - $script = @' - 'use strict'; - - const https = require('https'); - const http = require('http'); - const nodemailer = require('nodemailer'); - - const AZDO_SERVER_URL = (process.env.AZDO_SERVER_URL || '').replace(/\/$/, ''); - const AZDO_PAT = process.env.AZDO_PAT || ''; - const AZDO_PUBLISHER = ((process.env.AZDO_PUBLISHER || '').replace(/^\$\(.*\)$/, '')) || 'miguelnicolas'; - const AZDO_EXTENSION_ID = ((process.env.AZDO_EXTENSION_ID || '').replace(/^\$\(.*\)$/, '')) || 'timetracker-extension'; - 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_USER = process.env.SMTP_USER || null; - const SMTP_PASS = process.env.SMTP_PASS || null; - const DRY_RUN = process.env.DRY_RUN === 'true'; - const OVERRIDE_WEEK_START = /^\d{4}-\d{2}-\d{2}$/.test((process.env.OVERRIDE_WEEK_START || '').trim()) ? process.env.OVERRIDE_WEEK_START.trim() : ''; - - const REQUIRED_VARS = ['AZDO_SERVER_URL', 'AZDO_PAT', 'SMTP_HOST', 'SMTP_FROM']; - - function toDateStr(d) { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; - } - - function formatDisplayDate(dateStr) { - const d = new Date(dateStr + 'T00:00:00'); - return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' }); - } - - function getLastWeekRange() { - const now = new Date(); - now.setHours(0, 0, 0, 0); - const day = now.getDay(); - const daysToLastMonday = (day === 0 ? 6 : day - 1) + 7; - const monday = new Date(now); - monday.setDate(now.getDate() - daysToLastMonday); - const sunday = new Date(monday); - sunday.setDate(monday.getDate() + 6); - return { start: monday, end: sunday }; - } - - function getMonthKeysForRange(start, end) { - const keys = []; - const cur = new Date(start.getFullYear(), start.getMonth(), 1); - const endMon = new Date(end.getFullYear(), end.getMonth(), 1); - while (cur <= endMon) { - const y = cur.getFullYear(); - const m = String(cur.getMonth() + 1).padStart(2, '0'); - keys.push(`timetracker_${y}_${m}`); - cur.setMonth(cur.getMonth() + 1); - } - return keys; - } - - function authHeader() { - return 'Basic ' + Buffer.from(':' + AZDO_PAT).toString('base64'); - } - - function extDataBase() { - return `${AZDO_SERVER_URL}/_apis/ExtensionManagement/InstalledExtensions` + - `/${AZDO_PUBLISHER}/${AZDO_EXTENSION_ID}/Data/Scopes/Default/Current`; - } - - function fetchJson(url) { - return new Promise((resolve, reject) => { - const lib = url.startsWith('https') ? https : http; - const req = lib.get(url, { - headers: { Authorization: authHeader(), Accept: 'application/json' } - }, (res) => { - let body = ''; - res.on('data', chunk => body += chunk); - res.on('end', () => { - if (res.statusCode === 404) { resolve(null); return; } - if (res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0,200)}`)); return; } - try { resolve(JSON.parse(body)); } catch (e) { reject(e); } +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' + + - script: npm install nodemailer + displayName: Install nodemailer + + - task: PowerShell@2 + displayName: Write script to disk + inputs: + pwsh: false + targetType: inline + script: | + $script = @' + 'use strict'; + + const https = require('https'); + const http = require('http'); + const nodemailer = require('nodemailer'); + + const AZDO_SERVER_URL = (process.env.AZDO_SERVER_URL || '').replace(/\/$/, ''); + const AZDO_PAT = process.env.AZDO_PAT || ''; + const AZDO_PUBLISHER = ((process.env.AZDO_PUBLISHER || '').replace(/^\$\(.*\)$/, '')) || 'miguelnicolas'; + const AZDO_EXTENSION_ID = ((process.env.AZDO_EXTENSION_ID || '').replace(/^\$\(.*\)$/, '')) || 'timetracker-extension'; + 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'; + const OVERRIDE_WEEK_START = /^\d{4}-\d{2}-\d{2}$/.test((process.env.OVERRIDE_WEEK_START || '').trim()) ? process.env.OVERRIDE_WEEK_START.trim() : ''; + + const REQUIRED_VARS = ['AZDO_SERVER_URL', 'AZDO_PAT', 'SMTP_HOST', 'SMTP_FROM']; + + function toDateStr(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + function formatDisplayDate(dateStr) { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' }); + } + + function getLastWeekRange() { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const day = now.getDay(); + const daysToLastMonday = (day === 0 ? 6 : day - 1) + 7; + const monday = new Date(now); + monday.setDate(now.getDate() - daysToLastMonday); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + return { start: monday, end: sunday }; + } + + function getMonthKeysForRange(start, end) { + const keys = []; + const cur = new Date(start.getFullYear(), start.getMonth(), 1); + const endMon = new Date(end.getFullYear(), end.getMonth(), 1); + while (cur <= endMon) { + const y = cur.getFullYear(); + const m = String(cur.getMonth() + 1).padStart(2, '0'); + keys.push(`timetracker_${y}_${m}`); + cur.setMonth(cur.getMonth() + 1); + } + return keys; + } + + function authHeader() { + return 'Basic ' + Buffer.from(':' + AZDO_PAT).toString('base64'); + } + + function extDataBase() { + return `${AZDO_SERVER_URL}/_apis/ExtensionManagement/InstalledExtensions` + + `/${AZDO_PUBLISHER}/${AZDO_EXTENSION_ID}/Data/Scopes/Default/Current`; + } + + function fetchJson(url) { + return new Promise((resolve, reject) => { + const lib = url.startsWith('https') ? https : http; + const req = lib.get(url, { + headers: { Authorization: authHeader(), Accept: 'application/json' } + }, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode === 404) { resolve(null); return; } + if (res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0,200)}`)); return; } + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + }); + req.on('error', reject); }); - }); - req.on('error', reject); - }); - } - - async function fetchDocument(collectionName, debug) { - // VSS SDK getValue/setValue always uses the $settings built-in collection - const url = `${extDataBase()}/Collections/%24settings/Documents/${collectionName}?api-version=5.0-preview.1`; - if (debug) console.log(`[DEBUG] fetchDocument URL: ${url}`); - const doc = await fetchJson(url); - if (debug) console.log(`[DEBUG] fetchDocument raw response: ${JSON.stringify(doc)}`); - if (!doc) return null; - if ('__val__' in doc) return doc.__val__; - if ('value' in doc) return doc.value; - const result = {}; - for (const [k, v] of Object.entries(doc)) { - if (k !== 'id' && k !== '__etag' && k !== '__vso_document_version__') result[k] = v; - } - return result; - } - - const DAY_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; - - function escHtml(str) { - return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - } - - function buildEmailHtml(user, days, startStr, endStr) { - const total = days.reduce((sum, d) => sum + d.hours, 0); - const rows = days.map(d => { - const bg = d.hours === 0 ? '#fff4ce' : '#ffffff'; - const hoursCell = d.hours > 0 ? `${d.hours.toFixed(1)}` : ``; - return `${d.dayName}${formatDisplayDate(d.date)}${hoursCell}`; - }).join(''); - return `

Weekly Hours Summary

${startStr} – ${endStr}

Hi ${escHtml(user.userName)},

Here is your time logging summary for last week:

${rows}
DayDateHours
Total${total.toFixed(1)}

This is an automated weekly reminder from Time Tracker. Please keep your hours up to date in Azure DevOps.

`; - } - - async function main() { - const missing = REQUIRED_VARS.filter(v => !process.env[v]); - if (missing.length) { console.error('Missing required env vars:', missing.join(', ')); process.exit(1); } - if (DRY_RUN) console.log('[DRY RUN] No emails will be sent.'); - - let { start, end } = getLastWeekRange(); - if (OVERRIDE_WEEK_START) { - start = new Date(OVERRIDE_WEEK_START + 'T00:00:00'); - if (isNaN(start.getTime())) { console.error('Invalid OVERRIDE_WEEK_START:', OVERRIDE_WEEK_START); process.exit(1); } - end = new Date(start); - end.setDate(start.getDate() + 6); - console.log('[OVERRIDE] Using specified week.'); - } - - const startStr = toDateStr(start); - const endStr = toDateStr(end); - console.log(`Processing week: ${startStr} -> ${endStr}`); - - const config = await fetchDocument('notification-config', true); - console.log(`[DEBUG] config.users: ${JSON.stringify((config && config.users) || null)}`); - console.log(`[DEBUG] config.schedule: ${JSON.stringify((config && config.schedule) || null)}`); - - // Schedule check — the pipeline runs hourly; the script decides whether it's send time. - // Skipped when OVERRIDE_WEEK_START is set (manual test run). - if (!OVERRIDE_WEEK_START) { - const sched = config && config.schedule; - if (sched && sched.utcDay !== undefined && sched.utcHour !== undefined) { - const now = new Date(); - const curDay = now.getUTCDay(); - const curHour = now.getUTCHours(); - if (curDay !== sched.utcDay || curHour !== sched.utcHour) { - console.log(`Not send time. Configured: ${DAY_NAMES[sched.utcDay]} ${String(sched.utcHour).padStart(2,'0')}:00 UTC | Now: ${DAY_NAMES[curDay]} ${String(curHour).padStart(2,'0')}:00 UTC. Nothing to do.`); - return; + } + + async function fetchDocument(collectionName, debug) { + // VSS SDK getValue/setValue always uses the $settings built-in collection + const url = `${extDataBase()}/Collections/%24settings/Documents/${collectionName}?api-version=5.0-preview.1`; + if (debug) console.log(`[DEBUG] fetchDocument URL: ${url}`); + const doc = await fetchJson(url); + if (debug) console.log(`[DEBUG] fetchDocument raw response: ${JSON.stringify(doc)}`); + if (!doc) return null; + if ('__val__' in doc) return doc.__val__; + if ('value' in doc) return doc.value; + const result = {}; + for (const [k, v] of Object.entries(doc)) { + if (k !== 'id' && k !== '__etag' && k !== '__vso_document_version__') result[k] = v; } - console.log('Schedule matched — proceeding.'); + return result; } - } - - const enabledUsers = ((config && config.users) || []).filter(u => u.emailEnabled); - if (!enabledUsers.length) { console.log('No users have notifications enabled.'); return; } - console.log(`${enabledUsers.length} user(s) with notifications enabled.`); - - const monthKeys = getMonthKeysForRange(start, end); - let allEntries = []; - for (const key of monthKeys) { - const entries = await fetchDocument(key); - if (Array.isArray(entries)) allEntries = allEntries.concat(entries); - } - const weekEntries = allEntries.filter(e => e.date >= startStr && e.date <= endStr); - console.log(`${weekEntries.length} entries in range.`); - - const byUser = {}; - weekEntries.forEach(e => { if (!byUser[e.userId]) byUser[e.userId] = []; byUser[e.userId].push(e); }); - - const transportOptions = { - host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_SECURE, - family: 4, - connectionTimeout: 60000, - greetingTimeout: 60000, - socketTimeout: 120000, - }; - 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) { - const entries = byUser[user.userId] || []; - const days = []; - for (let i = 0; i < 7; i++) { - const d = new Date(start); d.setDate(start.getDate() + i); - const dateStr = toDateStr(d); - const hours = entries.filter(e => e.date === dateStr).reduce((s, e) => s + (Number(e.hours) || 0), 0); - days.push({ dayName: DAY_NAMES[d.getDay()], date: dateStr, hours }); + + const DAY_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + + function escHtml(str) { + return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - const ccList = (user.cc || '').split(',').map(s => s.trim()).filter(Boolean); - if (DRY_RUN) { - console.log(`[DRY RUN] -> ${user.userEmail}${ccList.length ? ' CC: '+ccList.join(', ') : ''} Total: ${days.reduce((s,d)=>s+d.hours,0).toFixed(1)} h`); - days.forEach(d => console.log(` ${d.dayName.padEnd(10)} ${d.date} ${d.hours.toFixed(1)} h`)); - sent++; continue; + + 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' }; } - try { - await transporter.sendMail({ from: SMTP_FROM, to: user.userEmail, cc: ccList.length ? ccList.join(', ') : undefined, subject: `Weekly Hours Summary: ${startStr} - ${endStr}`, html: buildEmailHtml(user, days, startStr, endStr) }); - console.log(`Sent -> ${user.userEmail}`); - sent++; - } catch(e) { console.error(`Failed -> ${user.userEmail}: ${e.message}`); failed++; } - } - 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.'); process.exit(1); }, 600000); - globalTimeout.unref(); - main().catch(err => { console.error('Fatal:', err); process.exit(1); }); - '@ - $script | Out-File -FilePath send-weekly-summary.js -Encoding utf8 - - - script: node send-weekly-summary.js - displayName: Send weekly hour summary emails - 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 }} + function buildTotalCell(total, target) { + const { color, label } = targetStatus(total, target); + if (!target || target <= 0) return `${total.toFixed(1)}`; + const pct = Math.round((total / target) * 100); + return `${total.toFixed(1)} / ${target}
${pct}% — ${label}`; + } + function buildEmailHtml(user, days, startStr, endStr) { + 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'; + const hoursCell = d.hours > 0 ? `${d.hours.toFixed(1)}` : ``; + return `${d.dayName}${formatDisplayDate(d.date)}${hoursCell}`; + }).join(''); + return `

Weekly Hours Summary

${startStr} – ${endStr}

Hi ${escHtml(user.userName)},

Here is your time logging summary for last week:

${rows}
DayDateHours
Total${buildTotalCell(total, target)}

This is an automated weekly reminder from Time Tracker. Please keep your hours up to date in Azure DevOps.

`; + } + + async function main() { + const missing = REQUIRED_VARS.filter(v => !process.env[v]); + if (missing.length) { console.error('Missing required env vars:', missing.join(', ')); process.exit(1); } + if (DRY_RUN) console.log('[DRY RUN] No emails will be sent.'); + + let { start, end } = getLastWeekRange(); + if (OVERRIDE_WEEK_START) { + start = new Date(OVERRIDE_WEEK_START + 'T00:00:00'); + if (isNaN(start.getTime())) { console.error('Invalid OVERRIDE_WEEK_START:', OVERRIDE_WEEK_START); process.exit(1); } + end = new Date(start); + end.setDate(start.getDate() + 6); + console.log('[OVERRIDE] Using specified week.'); + } + + const startStr = toDateStr(start); + const endStr = toDateStr(end); + console.log(`Processing week: ${startStr} -> ${endStr}`); + + const config = await fetchDocument('notification-config', true); + console.log(`[DEBUG] config.users: ${JSON.stringify((config && config.users) || null)}`); + console.log(`[DEBUG] config.schedule: ${JSON.stringify((config && config.schedule) || null)}`); + + // Schedule check — the pipeline runs hourly; the script decides whether it's send time. + // Skipped when OVERRIDE_WEEK_START is set (manual test run). + if (!OVERRIDE_WEEK_START) { + const sched = config && config.schedule; + if (sched && sched.utcDay !== undefined && sched.utcHour !== undefined) { + const now = new Date(); + const curDay = now.getUTCDay(); + const curHour = now.getUTCHours(); + if (curDay !== sched.utcDay || curHour !== sched.utcHour) { + console.log(`Not send time. Configured: ${DAY_NAMES[sched.utcDay]} ${String(sched.utcHour).padStart(2,'0')}:00 UTC | Now: ${DAY_NAMES[curDay]} ${String(curHour).padStart(2,'0')}:00 UTC. Nothing to do.`); + return; + } + console.log('Schedule matched — proceeding.'); + } + } + + const enabledUsers = ((config && config.users) || []).filter(u => u.emailEnabled); + if (!enabledUsers.length) { console.log('No users have notifications enabled.'); return; } + console.log(`${enabledUsers.length} user(s) with notifications enabled.`); + + const monthKeys = getMonthKeysForRange(start, end); + let allEntries = []; + for (const key of monthKeys) { + const entries = await fetchDocument(key); + if (Array.isArray(entries)) allEntries = allEntries.concat(entries); + } + const weekEntries = allEntries.filter(e => e.date >= startStr && e.date <= endStr); + console.log(`${weekEntries.length} entries in range.`); + + const byUser = {}; + weekEntries.forEach(e => { if (!byUser[e.userId]) byUser[e.userId] = []; byUser[e.userId].push(e); }); + + const transportOptions = { + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_SECURE, + ignoreTLS: SMTP_IGNORE_TLS, + family: 4, + connectionTimeout: 60000, + greetingTimeout: 60000, + socketTimeout: 120000, + }; + if (SMTP_USER) transportOptions.auth = { user: SMTP_USER, pass: SMTP_PASS }; + const transporter = nodemailer.createTransport(transportOptions); + + const mails = enabledUsers.map(user => { + const entries = byUser[user.userId] || []; + const days = []; + for (let i = 0; i < 7; i++) { + const d = new Date(start); d.setDate(start.getDate() + i); + const dateStr = toDateStr(d); + const hours = entries.filter(e => e.date === dateStr).reduce((s, e) => s + (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 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) } }; + }); + if (DRY_RUN) { + for (const { mailOptions, total } 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; + } + const MAX_ROUNDS = 12, RETRY_DELAY = 5 * 60 * 1000; + let sent = 0, failed = 0, 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}`); sent++; } + catch(e) { console.warn(`Round ${round} failed -> ${item.mailOptions.to}: ${e.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 65 minutes.'); process.exit(1); }, 65 * 60 * 1000); + globalTimeout.unref(); + main().catch(err => { console.error('Fatal:', err); process.exit(1); }); + '@ + $script | Out-File -FilePath send-weekly-summary.js -Encoding utf8 + + - script: node send-weekly-summary.js + displayName: Send weekly hour summary emails + 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 }} diff --git a/pipelines/weekly-summary.yml b/pipelines/weekly-summary.yml index 64f934a..44a8b65 100644 --- a/pipelines/weekly-summary.yml +++ b/pipelines/weekly-summary.yml @@ -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] # @@ -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 }} diff --git a/scripts/send-weekly-summary.js b/scripts/send-weekly-summary.js index 647de0c..dba96ff 100644 --- a/scripts/send-weekly-summary.js +++ b/scripts/send-weekly-summary.js @@ -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'; @@ -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 `${totalStr}`; + } + const pct = Math.round((total / target) * 100); + return `${totalStr} / ${target}` + + `
${pct}% — ${label}`; +} + +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'; @@ -210,7 +232,7 @@ function buildEmailHtml(user, days, startStr, endStr) { Total - ${total.toFixed(1)} + ${buildTotalCell(total, target)} @@ -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, @@ -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); @@ -344,34 +364,55 @@ 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}.`); @@ -379,9 +420,9 @@ async function main() { } 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 => { diff --git a/src/my-time.css b/src/my-time.css index e2c56eb..af5ad15 100644 --- a/src/my-time.css +++ b/src/my-time.css @@ -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; } @@ -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); } diff --git a/src/my-time.html b/src/my-time.html index 20350fe..8dd37e0 100644 --- a/src/my-time.html +++ b/src/my-time.html @@ -30,8 +30,15 @@

My Time

+
+ +
+
Loading…
-
This Week
+
+
This Week
+
+ + +
+
@@ -72,7 +85,7 @@

Untracked This Week

This Week In Range Last Logged - Quick Log + @@ -94,14 +107,22 @@

Untracked This Week

+ + diff --git a/src/notification-settings.html b/src/notification-settings.html deleted file mode 100644 index 73efe21..0000000 --- a/src/notification-settings.html +++ /dev/null @@ -1,393 +0,0 @@ - - - - - Notification Settings - - - - - - - -

Notification Settings

-

Configure weekly hour summary emails.

- -
Checking permissions…
- -
-
- Access denied. This page is only available to Project Administrators. - Contact a project admin to configure notification settings. -
-
- -
-
- Only users who have logged at least one hour appear in this list. - The CC field accepts multiple addresses separated by commas (e.g. a manager's email). -
- - -
-

Schedule

-
-
- - -
-
- - -
-
-

-

- The pipeline runs hourly and checks this setting at runtime — no YAML edits needed.
- To test: run the pipeline manually and set override_week_start to any Monday (YYYY-MM-DD). -

-
- - -
-

User Notification Configuration

-
Loading users…
- - -
- - -
-
-
- - - - - diff --git a/src/notification-settings.css b/src/settings.css similarity index 64% rename from src/notification-settings.css rename to src/settings.css index c14b26f..db2860f 100644 --- a/src/notification-settings.css +++ b/src/settings.css @@ -51,6 +51,7 @@ h1 { flex-shrink: 0; margin: 0 0 6px 0; font-weight: 600; } h2 { margin: 0 0 16px 0; font-weight: 600; font-size: 16px; } input[type="text"] { width: 100%; box-sizing: border-box; } +#totalHoursField { width: 100%; box-sizing: border-box; } .toggle-cell { text-align: center; } @@ -64,6 +65,58 @@ input[type="text"] { width: 100%; box-sizing: border-box; } min-height: 60px; } +.add-user-row { + flex-shrink: 0; + display: flex; + gap: 8px; + align-items: center; + margin-top: 12px; +} + +.add-user-search-wrap { + position: relative; + flex: 1; + max-width: 360px; +} + +.add-user-search-wrap input { + width: 100%; + box-sizing: border-box; +} + +.add-user-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: var(--shadow); + max-height: 200px; + overflow-y: auto; + z-index: 100; + margin-top: 2px; +} + +.add-user-result-row { + padding: 7px 10px; + cursor: pointer; + font-size: 13px; + line-height: 1.4; +} + +.add-user-result-row:hover { + background: var(--bg-hover); +} + +.add-user-result-row.muted { + color: var(--text-secondary); + cursor: default; +} + +.muted { color: var(--text-secondary); font-size: 12px; } + .btn-row { flex-shrink: 0; display: flex; @@ -72,9 +125,12 @@ input[type="text"] { width: 100%; box-sizing: border-box; } margin-top: 14px; } -.col-name { width: 200px; } -.col-email { width: 240px; } -.col-send { width: 110px; text-align: center; } +.col-name { width: 200px; } +.col-email { width: 240px; } +.col-send { width: 110px; text-align: center; } +.col-target { width: 90px; text-align: center; } +.target-cell { text-align: center; } +.target-cell input { width: 64px; text-align: right; } .schedule-row { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; } .schedule-group { display: flex; flex-direction: column; gap: 4px; } diff --git a/src/settings.html b/src/settings.html new file mode 100644 index 0000000..a235f56 --- /dev/null +++ b/src/settings.html @@ -0,0 +1,668 @@ + + + + + Settings + + + + + + + + +

Settings

+

Configure weekly hour summary emails.

+ +
Checking permissions…
+ +
+
+ Access denied. This page is only available to Project Administrators. + Contact a project admin to configure notification settings. +
+
+ +
+
+ Users who have logged time appear automatically. You can also add users manually below. + The CC field accepts multiple addresses separated by commas (e.g. a manager's email). +
+ + +
+

Schedule

+
+
+ + +
+
+ + +
+
+

+

+ The pipeline runs hourly and checks this setting at runtime — no YAML edits needed.
+ To test: run the pipeline manually and set override_week_start to any Monday (YYYY-MM-DD). +

+
+ + +
+

Total Logged Hours Field

+
+ + +
+

+ When set, every time entry add, edit, or delete fully recomputes this field from + scratch on the logged work item — and cascades up to its parent and epic, however + many levels away, so an epic's field reflects its own hours plus every descendant's. + It's always an absolute recalculation (never an incremental add), so it self-heals + after concurrent edits and after a work item is re-parented to a different epic. + Leave blank to disable. The field must already exist on the relevant work item types.

+ After setting this for the first time, go to Time Reports, widen the + date filter to cover your full history, and click Re-sync once — that + backfills every past month into the recalculation and brings existing totals up to date. +

+
+ + +
+
+ + +
+

User Notification Configuration

+
Loading users…
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ + + + + diff --git a/src/theme.css b/src/theme.css index f39d98d..1324f04 100644 --- a/src/theme.css +++ b/src/theme.css @@ -161,6 +161,16 @@ button.secondary:hover { background-color: var(--border); } +button.danger { + background-color: #c0392b !important; + color: #ffffff !important; + border: none; +} + +button.danger:hover { + background-color: #a93226 !important; +} + /* ================================================================ Tables ================================================================ */ @@ -241,3 +251,191 @@ tr:hover td { .status { font-size: 13px; } .status.success { color: var(--success-text); } .status.error { color: var(--error-text); } + +/* ================================================================ + Icon action buttons — shared across all pages + ================================================================ */ +.icon-btn { + background: transparent !important; + border: none; + padding: 0; + border-radius: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary) !important; + width: 28px; + height: 28px; + cursor: pointer; +} +.icon-btn:hover { background: var(--bg-row-hover) !important; } +.edit-btn:hover { color: var(--accent) !important; } +.delete-btn:hover { color: #d13438 !important; } +.add-btn:hover { color: var(--accent) !important; } +.actions-cell { white-space: nowrap; padding: 8px 12px !important; } + +/* ================================================================ + Edit entry modal — shared across all pages + ================================================================ */ +.edit-modal-backdrop { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} +.edit-modal-content { + background: var(--bg-card); + padding: 24px; + border-radius: 4px; + min-width: 300px; + max-width: 440px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + gap: 12px; +} +.edit-modal-content h3 { margin: 0; } +.edit-modal-content label { font-weight: 600; color: var(--text-primary) !important; font-size: 13px; } +.edit-modal-content input, +.edit-modal-content textarea { width: 100%; box-sizing: border-box; } +.edit-modal-actions { display: flex; gap: 8px; } + +/* Table links */ +td a { color: var(--accent); text-decoration: none; } +td a:hover { text-decoration: underline; } + +/* ================================================================ + Add-entry button (filter bar, far right) + ================================================================ */ +.filters-add { margin-left: auto; display: flex; align-items: flex-end; } + +.add-entry-btn { + display: inline-flex !important; + align-items: center; + gap: 6px; + background: transparent !important; + color: var(--text-secondary) !important; + border: 1px solid var(--border-input) !important; + padding: 5px 12px !important; + border-radius: 3px; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} +.add-entry-btn:hover { + color: var(--accent) !important; + border-color: var(--accent) !important; + background: var(--bg-accent-light) !important; +} + +/* ================================================================ + Add-entry modal: work item picker + ================================================================ */ +.tc-add-modal { max-width: 520px; width: 100%; } + +.tc-add-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.tc-add-header h3 { margin: 0; } + +.tc-modal-close { + background: transparent !important; + border: none; + color: var(--text-secondary) !important; + font-size: 18px; + line-height: 1; + padding: 2px 6px; + cursor: pointer; + border-radius: 3px; +} +.tc-modal-close:hover { background: var(--bg-row-hover) !important; color: var(--text-primary) !important; } + +.tc-search-input { width: 100%; box-sizing: border-box; } + +.tc-search-status { + font-size: 12px; + color: var(--text-secondary); + min-height: 18px; + margin: 4px 0; +} + +.tc-item-list { + border: 1px solid var(--border); + border-radius: 2px; + max-height: 180px; + overflow-y: auto; +} + +.tc-item-group { + padding: 5px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-secondary); + background: var(--bg-header); + border-bottom: 1px solid var(--border); +} + +.tc-item-row { + display: flex !important; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: transparent !important; + color: var(--text-primary) !important; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0 !important; + padding: 8px 12px !important; + cursor: pointer; + font-size: 13px; +} +.tc-item-row:last-child { border-bottom: none; } +.tc-item-row:hover { background: var(--bg-row-hover) !important; } + +.tc-item-type { + display: inline-block; + padding: 2px 7px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: var(--bg-accent-light); + color: var(--accent); + flex-shrink: 0; + white-space: nowrap; +} + +.tc-item-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.tc-item-empty { padding: 20px 12px; color: var(--text-secondary); font-size: 13px; text-align: center; } + +/* Selected work item chip — a clickable button that reopens the search */ +.tc-selected-chip { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: var(--bg-header) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border) !important; + border-radius: 4px; + padding: 8px 12px !important; + margin-bottom: 4px; + font-size: 13px; + cursor: pointer; +} +.tc-selected-chip:hover { + border-color: var(--accent) !important; + background: var(--bg-accent-light) !important; +} +/* Pencil icon: faintly visible at rest, full opacity on hover */ +.tc-chip-pencil { margin-left: auto; flex-shrink: 0; opacity: 0.25; } +.tc-selected-chip:hover .tc-chip-pencil { opacity: 0.8; } diff --git a/src/time-core.js b/src/time-core.js index 004ef88..2f488c3 100644 --- a/src/time-core.js +++ b/src/time-core.js @@ -1,4 +1,4 @@ -/* ================================================================ +/* ================================================================ time-core.js — shared storage + entry logic Loaded as a plain + @@ -41,7 +42,7 @@
- +
@@ -59,7 +60,19 @@

Time Entries

function applyDevOpsTheme() { function applyIfExplicit(t) { if (!t) return false; - // A non-empty theme that isn't a dark variant (e.g. "Default") means light. + // IMPORTANT — keep this simple toggle, do NOT copy the hub-page version here. + // + // This page runs as a sandboxed work-item-form contribution (cross-origin + // iframe). VSS context theme values are unreliable in this context, so the + // best-effort toggle below may be wrong. It does not matter: the async + // dataService.getValue('user-theme') call further down always overwrites it + // once it resolves. That async read is the real source of truth here. + // + // The hub-page version of applyIfExplicit returns false for unrecognised + // theme strings, letting execution fall through to the luminance-based + // parent-page background read. That fallback throws (cross-origin) in a + // sandboxed iframe, causing a flash to the wrong theme. Always returning + // true here prevents that fallback from running at all. document.documentElement.classList.toggle('dark', String(t).toLowerCase().indexOf('dark') !== -1); return true; } @@ -94,13 +107,19 @@

Time Entries

let currentUser = null; const STORAGE_KEY_PREFIX = "timetracker_"; const LEGACY_STORAGE_KEY = "timetracker_entries"; - - // Helper function to get storage key for a given date - function getStorageKeyForDate(dateString) { - var date = new Date(dateString); - var year = date.getFullYear(); - var month = String(date.getMonth() + 1).padStart(2, '0'); - return STORAGE_KEY_PREFIX + year + "_" + month; + var TC = window.TimeCore; + var EDIT_ICON = TC.EDIT_ICON; + var DELETE_ICON = TC.DELETE_ICON; + var escapeHtml = TC.escapeHtml; + var witClientCache = null; + function getWitClient() { + if (witClientCache) return Promise.resolve(witClientCache); + return new Promise(function(resolve) { + VSS.require(['VSS/Service', 'TFS/WorkItemTracking/RestClient'], function(VSS_Service, WIT_Client) { + witClientCache = VSS_Service.getCollectionClient(WIT_Client.WorkItemTrackingHttpClient); + resolve(witClientCache); + }); + }); } // Wait for VSS SDK to be available @@ -134,7 +153,7 @@

Time Entries

console.log("[TimeTracker] Got extension data service"); window.dataService = dataService; // Theme detection is blocked in sandboxed work-item-form iframes. - // Hub pages (time-report, notification-settings) save the detected theme + // Hub pages (time-report, settings) save the detected theme // to ExtensionData; read it here and apply it. dataService.getValue('user-theme', { scopeType: 'User' }).then(function(t) { if (t === 'dark') document.documentElement.classList.add('dark'); @@ -143,6 +162,7 @@

Time Entries

var webContext = VSS.getWebContext(); currentUser = webContext.user; + window.__projectName = webContext.project && webContext.project.name; console.log("[TimeTracker] Current user:", currentUser.name); // Register work item notification and field observer @@ -210,7 +230,7 @@

Time Entries

// Group entries by month var entriesByMonth = {}; legacyData.forEach(function(entry) { - var key = getStorageKeyForDate(entry.date); + var key = TC.getStorageKeyForDate(entry.date); if (!entriesByMonth[key]) { entriesByMonth[key] = []; } @@ -273,41 +293,6 @@

Time Entries

}); } - // Save a new entry to the appropriate monthly partition - function saveEntry(entry) { - var storageKey = getStorageKeyForDate(entry.date); - console.log("[TimeTracker] Saving entry to partition:", storageKey, "Entry:", entry); - - return window.dataService.getValue(storageKey, { scopeType: "Default" }).then(function(data) { - var monthEntries = data || []; - console.log("[TimeTracker] Current entries in partition:", monthEntries.length); - monthEntries.push(entry); - console.log("[TimeTracker] After adding new entry:", monthEntries.length); - return window.dataService.setValue(storageKey, monthEntries, { scopeType: "Default" }); - }).then(function(result) { - console.log("[TimeTracker] Save completed successfully"); - return result; - }, function(err) { - console.error("[TimeTracker] Save failed:", err); - throw err; - }); - } - - // Delete an entry from its monthly partition - function deleteEntryById(entryId, entryDate) { - var storageKey = getStorageKeyForDate(entryDate); - - return window.dataService.getValue(storageKey, { scopeType: "Default" }).then(function(data) { - if (!data || data.length === 0) { - throw new Error("No entries found for this month — aborting delete to prevent data loss"); - } - var filtered = data.filter(function(e) { - return e.id !== entryId; - }); - return window.dataService.setValue(storageKey, filtered, { scopeType: "Default" }); - }); - } - // Possible field reference names for Client (varies by where field was created in Azure DevOps) var CLIENT_FIELD_REFS = ["Custom.Client", "Custom.Planning_Client", "Planning.Client"]; var PROJECT_FIELD_REFS = ["Custom.Project", "Custom.Planning_Project", "Planning.Project"]; @@ -322,6 +307,17 @@

Time Entries

return defaultValue; } + function mergeTags(existing, additional) { + if (!additional) return existing; + if (!existing) return additional; + var seen = existing.split(";").map(function(t) { return t.trim(); }).filter(Boolean); + additional.split(";").forEach(function(t) { + var trimmed = t.trim(); + if (trimmed && seen.indexOf(trimmed) === -1) seen.push(trimmed); + }); + return seen.join("; "); + } + // Get all possible field names for API requests function getAllClientFieldRefs() { return CLIENT_FIELD_REFS.slice(); @@ -333,7 +329,7 @@

Time Entries

function saveTimeEntry() { var hours = parseFloat(document.getElementById("hours").value); var date = document.getElementById("date").value; - var description = document.getElementById("description").value; + var description = document.getElementById("description").value.trim(); if (!hours || hours <= 0 || hours > 24) { showMessage("Please enter valid hours (0.25 - 24)", "error"); @@ -343,6 +339,10 @@

Time Entries

showMessage("Please select a date", "error"); return; } + if (!description) { + showMessage("Please enter a description", "error"); + return; + } document.getElementById("saveBtn").disabled = true; @@ -354,10 +354,12 @@

Time Entries

// This avoids 400 errors when some custom fields don't exist function createEntry(workItem) { + var ctx = VSS.getWebContext(); return { id: Date.now().toString(), workItemId: workItemId, workItemTitle: workItem.fields["System.Title"], + teamProject: workItem.fields["System.TeamProject"] || (ctx.project || {}).name || "", parentId: workItem.fields["System.Parent"] || null, tags: workItem.fields["System.Tags"] || "", project: getFieldValue(workItem.fields, PROJECT_FIELD_REFS, "(No Project)"), @@ -375,98 +377,34 @@

Time Entries

function processWorkItem(workItem) { var entry = createEntry(workItem); - // If there's a parent, get its details (could be an Epic or Feature) - // Use expand=All (4) to get all fields including System.Parent - var parentPromise = entry.parentId - ? witClient.getWorkItem(entry.parentId, null, null, 4) - : Promise.resolve(null); - - parentPromise.then(function(parentItem) { - if (parentItem) { - entry.parentTitle = parentItem.fields["System.Title"]; - entry.parentType = parentItem.fields["System.WorkItemType"]; - - // Inherit tags from parent if task has no tags - if (!entry.tags && parentItem.fields["System.Tags"]) { - entry.tags = parentItem.fields["System.Tags"]; - entry.tagsInheritedFrom = "parent"; - } + TC.resolveAncestry(witClient, workItem).then(function(anc) { + entry.parentTitle = anc.parentTitle; + entry.parentType = anc.parentType; + entry.epicId = anc.epicId; + entry.epicTitle = anc.epicTitle; + entry.ancestorIds = anc.ancestors.map(function(item) { return item.id; }); - // Inherit project from parent if task has no project - var parentProject = getFieldValue(parentItem.fields, PROJECT_FIELD_REFS, null); - if (entry.project === "(No Project)" && parentProject) { - entry.project = parentProject; - entry.projectInheritedFrom = "parent"; + anc.ancestors.forEach(function(item) { + if (item.fields["System.Tags"]) { + entry.tags = mergeTags(entry.tags, item.fields["System.Tags"]); } + var inheritedFrom = item.id === anc.epicId ? "epic" : "parent"; - // Inherit client from parent if task has no client - var parentClient = getFieldValue(parentItem.fields, CLIENT_FIELD_REFS, null); - if (entry.client === "(No Client)" && parentClient) { - entry.client = parentClient; - entry.clientInheritedFrom = "parent"; + var itemProject = getFieldValue(item.fields, PROJECT_FIELD_REFS, null); + if (entry.project === "(No Project)" && itemProject) { + entry.project = itemProject; + entry.projectInheritedFrom = inheritedFrom; } - // If parent is an Epic, use it directly and inherit remaining properties - if (parentItem.fields["System.WorkItemType"] === "Epic") { - entry.epicId = entry.parentId; - entry.epicTitle = entry.parentTitle; - - // Inherit project from Epic if still no project (reuse parentProject from above) - if (entry.project === "(No Project)" && parentProject) { - entry.project = parentProject; - entry.projectInheritedFrom = "epic"; - } - - // Inherit client from Epic if still no client (reuse parentClient from above) - if (entry.client === "(No Client)" && parentClient) { - entry.client = parentClient; - entry.clientInheritedFrom = "epic"; - } - - return { parentItem: parentItem, grandparentItem: null }; - } - - // If parent has a parent, try to get it (could be Epic) - if (parentItem.fields["System.Parent"]) { - return witClient.getWorkItem(parentItem.fields["System.Parent"], null, null, 4).then(function(grandparentItem) { - return { parentItem: parentItem, grandparentItem: grandparentItem }; - }); - } - } - return { parentItem: parentItem, grandparentItem: null }; - }).then(function(result) { - var parentItem = result ? result.parentItem : null; - var grandparentItem = result ? result.grandparentItem : null; - - if (grandparentItem) { - if (grandparentItem.fields["System.WorkItemType"] === "Epic") { - entry.epicId = grandparentItem.id; - entry.epicTitle = grandparentItem.fields["System.Title"]; - - // Inherit tags from grandparent (Epic) if still no tags - if (!entry.tags && grandparentItem.fields["System.Tags"]) { - entry.tags = grandparentItem.fields["System.Tags"]; - entry.tagsInheritedFrom = "epic"; - } - - // Inherit project from grandparent (Epic) if still no project - var grandparentProject = getFieldValue(grandparentItem.fields, PROJECT_FIELD_REFS, null); - if (entry.project === "(No Project)" && grandparentProject) { - entry.project = grandparentProject; - entry.projectInheritedFrom = "epic"; - } - - // Inherit client from grandparent (Epic) if still no client - var grandparentClient = getFieldValue(grandparentItem.fields, CLIENT_FIELD_REFS, null); - if (entry.client === "(No Client)" && grandparentClient) { - entry.client = grandparentClient; - entry.clientInheritedFrom = "epic"; - } + var itemClient = getFieldValue(item.fields, CLIENT_FIELD_REFS, null); + if (entry.client === "(No Client)" && itemClient) { + entry.client = itemClient; + entry.clientInheritedFrom = inheritedFrom; } - } + }); // Save entry to monthly partition - saveEntry(entry).then(function() { + TC.saveEntry(window.dataService, entry, witClient).then(function() { showMessage("Time entry saved!", "success"); document.getElementById("hours").value = ""; document.getElementById("description").value = ""; @@ -514,16 +452,17 @@

Time Entries

}, 0); listEl.innerHTML = workItemEntries.map(function(e) { - var canDelete = e.userId === currentUser.id; + var canModify = e.userId === currentUser.id; return '
' + '
' + '' + escapeHtml(e.userName) + '' + '' + (e.description ? '' + escapeHtml(e.description) + '' : '') + '
' + - '
' + + '
' + '' + e.hours + 'h' + - (canDelete ? '' : '') + + (canModify ? '' : '') + + (canModify ? '' : '') + '
' + '
'; }).join(""); @@ -546,20 +485,20 @@

Time Entries

showMessage("Entry not found", "error"); return; } + if (!currentUser || entry.userId !== currentUser.id) { + showMessage("You can only delete your own entries", "error"); + return; + } - return deleteEntryById(entryId, entry.date); + return getWitClient().then(function(client) { + return TC.deleteEntryById(window.dataService, entryId, entry.date, client); + }); }).then(function() { showMessage("Entry deleted", "success"); loadTimeEntries(); }); } - function escapeHtml(text) { - var div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - } - // Setup field observer for state changes function setupFieldObserver() { VSS.require(["VSS/Service", "TFS/WorkItemTracking/Services"], function(VSS_Service, WIT_Services) { @@ -609,11 +548,8 @@

Time Entries

// Set default date to today nudgeDate.valueAsDate = new Date(); - // Show the banner with animation banner.style.display = "block"; - - // Scroll to top to make it visible - window.scrollTo({ top: 0, behavior: "smooth" }); + document.getElementById("app").scrollTo({ top: 0, behavior: "smooth" }); } // Dismiss the nudge banner @@ -650,6 +586,30 @@

Time Entries

// Trigger the save saveTimeEntry(); } + + function editEntry(entryId) { + getAllEntries().then(function(entries) { + var entry = entries.find(function(e) { return e.id === entryId; }); + if (!entry) { showMessage('Entry not found', 'error'); return; } + if (!currentUser || entry.userId !== currentUser.id) { showMessage('You can only edit your own entries', 'error'); return; } + TC.openAddEntryModal({ + dataService: window.dataService, + witClientGetter: getWitClient, + currentUser: currentUser, + projectName: window.__projectName, + recentItems: [{ id: entry.workItemId, title: entry.workItemTitle || '(untitled)', type: null }], + title: 'Edit Time Entry', + saveLabel: 'Save', + initialItem: { id: entry.workItemId, title: entry.workItemTitle || '(untitled)', type: null }, + initialHours: entry.hours, + initialDate: entry.date, + initialDesc: entry.description, + entryId: entry.id, + originalDate: entry.date, + onSaved: function() { showMessage('Entry updated', 'success'); loadTimeEntries(); } + }); + }); + } diff --git a/src/time-report.css b/src/time-report.css index a75b767..ca817aa 100644 --- a/src/time-report.css +++ b/src/time-report.css @@ -100,3 +100,4 @@ td { padding: 12px 16px; color: var(--text-primary); } color: var(--accent); font-weight: 600; } + diff --git a/src/time-report.html b/src/time-report.html index 5d8317b..f5c9122 100644 --- a/src/time-report.html +++ b/src/time-report.html @@ -7,6 +7,7 @@ +

Time Reports

@@ -55,6 +56,13 @@

Time Reports

+ +
+
+
@@ -147,6 +155,164 @@

Work Items

let filteredEntries = []; let currentView = "entries"; var pendingFilters = null; + let currentUser = null; + var collectionUri = ""; + var currentProjectName = ""; + var TC = window.TimeCore; + var EDIT_ICON = TC.EDIT_ICON; + var DELETE_ICON = TC.DELETE_ICON; + var escapeHtml = TC.escapeHtml; + var _witClient = null; + + function getWitClient() { + if (_witClient) return Promise.resolve(_witClient); + return new Promise(function(resolve) { + VSS.require(['VSS/Service', 'TFS/WorkItemTracking/RestClient'], function(VSS_Service, WIT_Client) { + _witClient = VSS_Service.getCollectionClient(WIT_Client.WorkItemTrackingHttpClient); + resolve(_witClient); + }); + }); + } + + var checkIsAdmin = TC.checkIsAdmin; + + function fetchTagsForWorkItem(witClient, workItemId) { + return witClient.getWorkItem(workItemId, null, null, 4).then(function(wi) { + var meta = { + workItemTitle: wi.fields['System.Title'], + tags: wi.fields['System.Tags'] || '', + parentId: wi.fields['System.Parent'] || null, + parentTitle: null, + parentType: null, + epicId: null, + epicTitle: null + }; + return TC.resolveAncestry(witClient, wi).then(function(anc) { + meta.parentTitle = anc.parentTitle; + meta.parentType = anc.parentType; + meta.epicId = anc.epicId; + meta.epicTitle = anc.epicTitle; + meta.ancestorIds = anc.ancestors.map(function(item) { return item.id; }); + anc.ancestors.forEach(function(item) { + if (item.fields['System.Tags']) { + meta.tags = TC.mergeTags(meta.tags, item.fields['System.Tags']); + } + }); + return meta; + }); + }); + } + + function batchUpdateEntries(entryUpdates) { + var byKey = {}; + entryUpdates.forEach(function(u) { + var key = TC.getStorageKeyForDate(u.date); + if (!byKey[key]) byKey[key] = []; + byKey[key].push(u); + }); + return Promise.all(Object.keys(byKey).map(function(key) { + var updates = byKey[key]; + return window.dataService.getValue(key, { scopeType: 'Default' }).then(function(data) { + var monthEntries = data || []; + var updById = {}; + updates.forEach(function(u) { updById[u.id] = u.updates; }); + var updated = monthEntries.map(function(e) { + return updById[e.id] ? Object.assign({}, e, updById[e.id]) : e; + }); + return window.dataService.setValue(key, updated, { scopeType: 'Default' }); + }); + })); + } + + function resyncAll() { + if (filteredEntries.length === 0) { + alert('No entries in current filter to update.'); + return; + } + if (!confirm('Re-sync tags, parent and epic for ' + filteredEntries.length + ' filtered entries from Azure DevOps?')) return; + + var btn = document.getElementById('resyncBtn'); + btn.disabled = true; + + // Deduplicate by workItemId — fetch each work item once + var byWorkItem = {}; + filteredEntries.forEach(function(e) { + if (!byWorkItem[e.workItemId]) byWorkItem[e.workItemId] = []; + byWorkItem[e.workItemId].push(e); + }); + var workItemIds = Object.keys(byWorkItem); + var total = workItemIds.length; + var done = 0; + var entryUpdates = []; + var metaByWorkItem = {}; + + getWitClient().then(function(witClient) { + function processBatch(ids) { + if (ids.length === 0) return Promise.resolve(); + var batch = ids.slice(0, 5); + var rest = ids.slice(5); + return Promise.all(batch.map(function(wiId) { + return fetchTagsForWorkItem(witClient, wiId).then(function(meta) { + metaByWorkItem[wiId] = meta; + byWorkItem[wiId].forEach(function(e) { + entryUpdates.push({ id: e.id, date: e.date, updates: meta }); + }); + }).catch(function() {}).then(function() { + done++; + btn.textContent = 'Re-syncing... ' + done + '/' + total; + }); + })).then(function() { return processBatch(rest); }); + } + + btn.textContent = 'Re-syncing... 0/' + total; + return processBatch(workItemIds).then(function() { + return batchUpdateEntries(entryUpdates); + }).then(function() { + // Recompute total-hours (if configured) for every work item this + // resync touched, using the freshly-resynced ancestor chains — + // this is the explicit "fix everything now" path for hours stuck + // on a work item after it was re-parented in Azure DevOps. + var touchedEntries = []; + workItemIds.forEach(function(wiId) { + var meta = metaByWorkItem[wiId]; + if (!meta) return; + byWorkItem[wiId].forEach(function(e) { + touchedEntries.push(Object.assign({}, e, meta)); + }); + }); + return TC.recalcTotalHours(window.dataService, witClient, touchedEntries); + }).then(function() { + btn.disabled = false; + btn.textContent = 'Re-sync'; + return loadAllEntries(); + }); + }).catch(function(err) { + btn.disabled = false; + btn.textContent = 'Re-sync'; + alert('Re-sync failed: ' + err.message); + }); + } + + function openAddEntry() { + var seen = {}, recent = []; + // Build recent list from current user's entries, newest first. + allEntries.filter(function(e) { + return currentUser && String(e.userId) === String(currentUser.id); + }).slice().sort(function(a, b) { + return b.date > a.date ? -1 : b.date < a.date ? 1 : 0; + }).forEach(function(e) { + var k = String(e.workItemId); + if (!seen[k]) { seen[k] = true; recent.push({ id: e.workItemId, title: e.workItemTitle || '(untitled)', type: null }); } + }); + TC.openAddEntryModal({ + dataService: window.dataService, + witClientGetter: getWitClient, + currentUser: currentUser, + projectName: currentProjectName, + recentItems: recent.slice(0, 20), + onSaved: function() { loadAllEntries(); } + }); + } function parseHashString(hash) { if (!hash) return null; @@ -184,14 +350,6 @@

Work Items

}); } - // Helper function to get storage key for a given date - function getStorageKeyForDate(dateString) { - var date = new Date(dateString); - var year = date.getFullYear(); - var month = String(date.getMonth() + 1).padStart(2, '0'); - return STORAGE_KEY_PREFIX + year + "_" + month; - } - // Get all months between two dates function getMonthsBetween(startDate, endDate) { var months = []; @@ -233,6 +391,15 @@

Work Items

VSS.getService(VSS.ServiceIds.ExtensionData).then(function(dataService) { console.log("[TimeReport] Got extension data service"); window.dataService = dataService; + try { + var ctx = VSS.getWebContext(); + currentUser = ctx.user; + collectionUri = ((ctx.collection || {}).uri || "").replace(/\/$/, ""); + currentProjectName = (ctx.project || {}).name || ""; + checkIsAdmin().then(function(admin) { + if (admin) document.getElementById('resyncBtn').style.display = ''; + }); + } catch(e) {} try { dataService.setValue('user-theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light', { scopeType: 'User' }); } catch(e) {} // Set default date range (last 30 days) @@ -393,6 +560,7 @@

Work Items

opt.textContent = user.name; userSelect.appendChild(opt); }); + userSelect.closest('.filter-group').style.display = users.length > 1 ? '' : 'none'; // Unique epics var epics = [...new Set(allEntries.filter(e => e.epicId).map(e => JSON.stringify({id: e.epicId, title: e.epicTitle})))]; @@ -405,6 +573,7 @@

Work Items

opt.textContent = epic.title; epicSelect.appendChild(opt); }); + epicSelect.closest('.filter-group').style.display = epics.length > 0 ? '' : 'none'; // Unique projects var projects = [...new Set(allEntries.map(e => e.project || "(No Project)"))]; @@ -416,6 +585,7 @@

Work Items

opt.textContent = proj; projectSelect.appendChild(opt); }); + projectSelect.closest('.filter-group').style.display = projects.some(function(p) { return p !== '(No Project)'; }) ? '' : 'none'; // Unique clients var clients = [...new Set(allEntries.map(e => e.client || "(No Client)"))]; @@ -427,6 +597,7 @@

Work Items

opt.textContent = cli; clientSelect.appendChild(opt); }); + clientSelect.closest('.filter-group').style.display = clients.some(function(c) { return c !== '(No Client)'; }) ? '' : 'none'; // Unique tags var allTags = new Set(); @@ -446,6 +617,7 @@

Work Items

opt.textContent = tag; tagSelect.appendChild(opt); }); + tagSelect.closest('.filter-group').style.display = allTags.size > 0 ? '' : 'none'; // Restore selections after rebuild document.getElementById("userFilter").value = savedUser; @@ -556,7 +728,7 @@

Work Items

var thead = document.getElementById("tableHead"); var tbody = document.getElementById("tableBody"); - thead.innerHTML = "DateUserWork ItemParentEpicTagsHoursDescription"; + thead.innerHTML = "DateUserWork ItemParentEpicTagsHoursDescription"; var sorted = [...filteredEntries].sort((a, b) => new Date(b.date) - new Date(a.date)); @@ -565,21 +737,33 @@

Work Items

'' + escapeHtml(t) + '' ).join("") : "-"; - // Show parent (User Story, Feature, etc.) if available and not an Epic + // Show the immediate parent, whatever its type (including Epic) var parentDisplay = "-"; - if (e.parentTitle && e.parentType && e.parentType !== "Epic") { + if (e.parentTitle && e.parentType) { parentDisplay = escapeHtml(e.parentTitle) + " (" + escapeHtml(e.parentType) + ")"; } + var canModify = currentUser && e.userId === currentUser.id; + var actionBtns = canModify + ? '' + + '' + : ''; + + var wiUrl = getWorkItemUrl(e); + var wiIdLabel = wiUrl + ? '#' + e.workItemId + '' + : '#' + e.workItemId; + return "" + "" + e.date + "" + "" + escapeHtml(e.userName) + "" + - "" + escapeHtml(e.workItemTitle) + " (#" + e.workItemId + ")" + + "" + escapeHtml(e.workItemTitle) + " (" + wiIdLabel + ")" + "" + parentDisplay + "" + "" + (e.epicTitle ? escapeHtml(e.epicTitle) : "-") + "" + "" + tags + "" + "" + e.hours + "h" + "" + (e.description ? escapeHtml(e.description) : "-") + "" + + "" + actionBtns + "" + ""; }).join(""); } @@ -1099,12 +1283,58 @@

Work Items

link.click(); } - function escapeHtml(text) { - if (!text) return ""; - var div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; + function getWorkItemUrl(entry) { + var base = collectionUri.replace(/\/$/, ""); + var proj = entry.teamProject || currentProjectName || ""; + if (!base || !proj) return null; + return base + "/" + encodeURIComponent(proj) + "/_workitems/edit/" + entry.workItemId; + } + + function deleteEntry(entryId) { + if (!confirm("Delete this time entry?")) return; + var entry = allEntries.find(function(e) { return e.id === entryId; }); + if (!entry) { alert("Entry not found"); return; } + if (!currentUser || entry.userId !== currentUser.id) { alert("You can only delete your own entries"); return; } + getWitClient().then(function(client) { + return TC.deleteEntryById(window.dataService, entryId, entry.date, client); + }).then(function() { + loadAllEntries(); + }, function() { + alert("Failed to delete entry"); + }); + } + + function editEntry(entryId) { + var entry = allEntries.find(function(e) { return e.id === entryId; }); + if (!entry) { alert('Entry not found'); return; } + if (!currentUser || entry.userId !== currentUser.id) { alert('You can only edit your own entries'); return; } + var seen = {}, recent = []; + allEntries.filter(function(e) { + return currentUser && String(e.userId) === String(currentUser.id); + }).slice().sort(function(a, b) { + return b.date > a.date ? -1 : b.date < a.date ? 1 : 0; + }).forEach(function(e) { + var k = String(e.workItemId); + if (!seen[k]) { seen[k] = true; recent.push({ id: e.workItemId, title: e.workItemTitle || '(untitled)', type: null }); } + }); + TC.openAddEntryModal({ + dataService: window.dataService, + witClientGetter: getWitClient, + currentUser: currentUser, + projectName: currentProjectName, + recentItems: recent.slice(0, 20), + title: 'Edit Time Entry', + saveLabel: 'Save', + initialItem: { id: entry.workItemId, title: entry.workItemTitle || '(untitled)', type: null }, + initialHours: entry.hours, + initialDate: entry.date, + initialDesc: entry.description, + entryId: entry.id, + originalDate: entry.date, + onSaved: function() { loadAllEntries(); } + }); } + diff --git a/vss-extension.dev.json b/vss-extension.dev.json index 7c3aa93..cc7acf2 100644 --- a/vss-extension.dev.json +++ b/vss-extension.dev.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/vss-extension", "manifestVersion": 1, "id": "timetracker-extension-dev", - "version": "1.5.5", + "version": "1.5.33", "name": "Time Tracker (DEV)", "description": "[DEVELOPMENT] Simple time tracking for user stories with reporting by user, epic, tag and date range", "publisher": "miguelnicolas", @@ -78,14 +78,14 @@ } }, { - "id": "notification-settings-hub", + "id": "settings-hub", "type": "ms.vss-web.hub", "targets": [ "ms.vss-work-web.work-hub-group" ], "properties": { - "name": "Notification Settings (DEV)", - "uri": "src/notification-settings.html", + "name": "Settings (DEV)", + "uri": "src/settings.html", "icon": "static/icon.png", "order": 30 } @@ -114,6 +114,7 @@ "scopes": [ "vso.work", "vso.work_write", - "vso.extension.data_write" + "vso.extension.data_write", + "vso.identity" ] } diff --git a/vss-extension.json b/vss-extension.json index bef6f79..c9f1f13 100644 --- a/vss-extension.json +++ b/vss-extension.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/vss-extension", "manifestVersion": 1, "id": "timetracker-extension", - "version": "1.6.1", + "version": "1.6.5", "name": "Time Tracker", "description": "Simple time tracking for user stories with reporting by user, epic, tag and date range", "publisher": "miguelnicolas", @@ -77,14 +77,14 @@ } }, { - "id": "notification-settings-hub", + "id": "settings-hub", "type": "ms.vss-web.hub", "targets": [ "ms.vss-work-web.work-hub-group" ], "properties": { - "name": "Notification Settings", - "uri": "src/notification-settings.html", + "name": "Settings", + "uri": "src/settings.html", "icon": "static/icon.png", "order": 30 } @@ -113,6 +113,7 @@ "scopes": [ "vso.work", "vso.work_write", - "vso.extension.data_write" + "vso.extension.data_write", + "vso.identity" ] }