diff --git a/operations/csm-scheduled-tasks/internal/notify/migration_case_report.go b/operations/csm-scheduled-tasks/internal/notify/migration_case_report.go index 798ba14b3e..740b961479 100644 --- a/operations/csm-scheduled-tasks/internal/notify/migration_case_report.go +++ b/operations/csm-scheduled-tasks/internal/notify/migration_case_report.go @@ -73,9 +73,14 @@ const migrationRowColumns = 9 // rather than rendering a header over nothing. func renderMigrationRows(rows []migrationreport.Row, caseLinkBase string, loc *time.Location) string { if len(rows) == 0 { - return fmt.Sprintf(`No migrations in this table.`, migrationRowColumns) + return fmt.Sprintf(`No migrations in this table.`, migrationRowColumns) } - const cell = `%s` + // Tight padding and an explicit line-height: nine columns over a + // couple of hundred rows, so every extra pixel per row is a screen of + // scrolling. The two timestamp columns are held on one line each -- + // they wrapped before, which turned most rows into two. + const cell = `%s` + const nowrapCell = `%s` const stamp = "2006-01-02 15:04:05" var b strings.Builder for _, row := range rows { @@ -86,10 +91,10 @@ func renderMigrationRows(rows []migrationreport.Row, caseLinkBase string, loc *t owners := strings.Join(nonEmpty(row.AccountManager, row.TechnicalOwner), " / ") b.WriteString("") fmt.Fprintf(&b, cell, escapeHTML(row.Number)) - fmt.Fprintf(&b, cell, migrationCaseLink(caseLinkBase, row)) - fmt.Fprintf(&b, cell, escapeHTML(row.CreatedOn.In(loc).Format(stamp))) - fmt.Fprintf(&b, cell, orDash(escapeHTML(lastComment))) - fmt.Fprintf(&b, cell, orDash(escapeHTML(row.AssignedTo))) + fmt.Fprintf(&b, nowrapCell, migrationCaseLink(caseLinkBase, row)) + fmt.Fprintf(&b, nowrapCell, escapeHTML(row.CreatedOn.In(loc).Format(stamp))) + fmt.Fprintf(&b, nowrapCell, orDash(escapeHTML(lastComment))) + fmt.Fprintf(&b, nowrapCell, orDash(escapeHTML(row.AssignedTo))) fmt.Fprintf(&b, cell, orDash(escapeHTML(row.Product))) fmt.Fprintf(&b, cell, orDash(escapeHTML(row.CsTeam))) fmt.Fprintf(&b, cell, orDash(escapeHTML(row.SalesRegion))) diff --git a/operations/csm-scheduled-tasks/internal/notify/templates/migration_case_report.html b/operations/csm-scheduled-tasks/internal/notify/templates/migration_case_report.html index dd2a842231..bdb6218ad2 100644 --- a/operations/csm-scheduled-tasks/internal/notify/templates/migration_case_report.html +++ b/operations/csm-scheduled-tasks/internal/notify/templates/migration_case_report.html @@ -25,7 +25,7 @@ .footerContent a:hover { color: #ff7300 !important; } @media only screen and (max-width: 650px) { - .bodyContent { padding: 0 20px 30px !important; } + .bodyContent { padding: 0 16px 24px !important; } } @media (prefers-color-scheme: dark) { @@ -47,17 +47,17 @@ - +
-
- +
+ - - @@ -68,9 +68,9 @@
-
+ WSO2 Logo
+ Weekly Migration Case Scan
+
-
+

Hi Team,

@@ -83,19 +83,19 @@ A migration appears in both of the first two tables when it carries both tags; the third table lists the migrations carrying neither.

-

Migrations Customer Requested to Hold ()

+

Migrations Customer Requested to Hold ()

- - - - - - - - - + + + + + + + + + @@ -103,19 +103,19 @@
Case IDWSO2 IDCreated DateLast Customer CommentAssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical OwnerCase IDWSO2 IDCreated DateLast Customer
Comment
AssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical Owner
-

Migrations Waiting on Accounts Team ()

+

Migrations Waiting on Accounts Team ()

- - - - - - - - - + + + + + + + + + @@ -123,19 +123,19 @@
Case IDWSO2 IDCreated DateLast Customer CommentAssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical OwnerCase IDWSO2 IDCreated DateLast Customer
Comment
AssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical Owner
-

Migrations in Progress for Over Days That Don't Fall Under Either of the Above Categories ()

+

Migrations in Progress for Over Days That Don't Fall Under Either of the Above Categories ()

- - - - - - - - - + + + + + + + + + @@ -153,7 +153,7 @@
Case IDWSO2 IDCreated DateLast Customer CommentAssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical OwnerCase IDWSO2 IDCreated DateLast Customer
Comment
AssigneeWSO2 ProductIntegration CS TeamSales RegionAccount Manager / Technical Owner
- +
diff --git a/operations/csm-scheduled-tasks/internal/notify/templates/worklogs_report.html b/operations/csm-scheduled-tasks/internal/notify/templates/worklogs_report.html index 1ba6c1ce3b..df0556bd2b 100644 --- a/operations/csm-scheduled-tasks/internal/notify/templates/worklogs_report.html +++ b/operations/csm-scheduled-tasks/internal/notify/templates/worklogs_report.html @@ -25,7 +25,7 @@ .footerContent a:hover { color: #ff7300 !important; } @media only screen and (max-width: 650px) { - .bodyContent { padding: 0 20px 30px !important; } + .bodyContent { padding: 0 16px 24px !important; } } @media (prefers-color-scheme: dark) { @@ -37,6 +37,14 @@ .footerContent a { color: #bbbdc1 !important; } .reportTable th { background-color: #33383d !important; color: #e2e2e2 !important; } .reportTable td { border-color: #454b52 !important; } + /* The summary grid and the team strip set light backgrounds + inline. Without these the rule above paints their text + #e2e2e2 and leaves the background white -- light on light, + unreadable. The pills are safe: their colours sit on divs, + which that rule does not target. */ + .statGrid { background-color: #454b52 !important; } + .statGrid td { background-color: #33383d !important; } + .teamStrip, .teamStrip td { background-color: #3b332c !important; border-color: #5a4a39 !important; } } @@ -47,17 +55,17 @@
- +
- `) - } else { - b.WriteString(``) + secondLabel, lastLabel = "Leave", "Disapproved Entries" } - b.WriteString(``) + b.WriteString(`
- + ` +// renderWorklogBreakdowns is the five cards between the grid and the team +// sections. Members and Approved Time break down by team only; the other +// three also carry the work-item types, exactly as the original does. +func renderWorklogBreakdowns(r worklogreport.Report) string { + if len(r.Teams) == 0 && len(r.ByType) == 0 { + return `

No time was logged in this period.

` } - var b strings.Builder - for _, t := range r.Teams { - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) + teamPills := func(value func(worklogreport.TeamReport) string) string { + cells := make([]pillCell, 0, len(r.Teams)) + for _, t := range r.Teams { + cells = append(cells, pillCell{t.TeamName, value(t)}) + } + return pillRow(cells) } - return b.String() -} - -func renderWorklogTypes(r worklogreport.Report) string { - if len(r.ByType) == 0 { - return `` + typePills := func(value func(worklogreport.TypeBucket) string) string { + cells := make([]pillCell, 0, len(r.ByType)) + for _, t := range r.ByType { + cells = append(cells, pillCell{t.Label, value(t)}) + } + return pillRow(cells) } + var b strings.Builder - for _, t := range r.ByType { - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - } + b.WriteString(breakdownCard("Members", strconv.Itoa(r.Totals.Members), + teamPills(func(t worklogreport.TeamReport) string { return strconv.Itoa(t.Members) }))) + b.WriteString(breakdownCard("Issues", strconv.Itoa(r.Totals.Issues), + teamPills(func(t worklogreport.TeamReport) string { return strconv.Itoa(t.Issues) }), + typePills(func(t worklogreport.TypeBucket) string { return strconv.Itoa(t.Issues) }))) + b.WriteString(breakdownCard("Approved Time", hrs(r.Totals.ApprovedHours), + teamPills(func(t worklogreport.TeamReport) string { return hrs(t.ApprovedHours) }), + typePills(func(t worklogreport.TypeBucket) string { return hrs(t.ApprovedHours) }))) + b.WriteString(breakdownCard("Pending Time", hrs(r.Totals.PendingHours), + teamPills(func(t worklogreport.TeamReport) string { return hrs(t.PendingHours) }), + typePills(func(t worklogreport.TypeBucket) string { return hrs(t.PendingHours) }))) + b.WriteString(breakdownCard("Total Time", hrs(r.Totals.TotalHours), + teamPills(func(t worklogreport.TeamReport) string { return hrs(t.TotalHours) }), + typePills(func(t worklogreport.TypeBucket) string { return hrs(t.TotalHours) }))) return b.String() } // renderWorklogTeamSections builds the per-team developer tables -- the -// bulk of the mail. The weekly report carries worked days and an average -// per day; the daily one carries leave and disapproved entries instead. +// bulk of the mail. Each opens with the team's own figures on one strip, +// then the member table, whose header groups the approved and pending +// columns under a shared heading the way the ServiceNow report does. +// +// The weekly report carries worked days and an average per day where the +// daily one carries leave and disapproved entries; everything else is +// shared. func renderWorklogTeamSections(r worklogreport.Report, daily bool, caseLinkBase string) string { + const ( + stat = `style="text-align:center; padding:6px 8px;"` + statName = `style="padding:6px 10px; width:18%;"` + grpHead = `style="background-color:#f1f3f5; border-right:2px solid #ffffff; text-align:center; font-weight:700; padding:5px 8px; border-bottom:1px solid #dfe3e6;"` + subHead = `style="background-color:#f1f3f5; border-right:2px solid #ffffff; text-align:center; padding:4px 8px;"` + devCell = `style="padding:4px 8px; line-height:16px; border-bottom:1px solid #e8eaed;"` + devMid = `style="padding:4px 8px; line-height:16px; text-align:center; border-bottom:1px solid #e8eaed;"` + ) var b strings.Builder for _, t := range r.Teams { - b.WriteString(`

` + - escapeHTML(t.TeamName) + `

`) - b.WriteString(`

` + - strconv.Itoa(t.Members) + ` members · ` + strconv.Itoa(t.Issues) + - ` issues · ` + hrs(t.ApprovedHours) + ` approved hrs · ` + - hrs(t.PendingHours) + ` pending hrs · ` + - hrs(t.AvgTotalHoursPerPerson) + ` avg total hrs/person

`) - - b.WriteString(`
+ - - @@ -68,55 +76,15 @@
-
+ WSO2 Logo
+ CS Worklogs · Report
+
-
+ -

CS Worklogs — Report

-

- -

- - - - - -
- -

By team

- - - - - - - - - - - - - - - -
TeamMembersIssuesApprovedPendingTotalAvg/person
- -

By type

- - - - - - - - - - - - - -
TypeIssuesApprovedPendingTotal
+

CS Worklogs — Report

+

+ +

@@ -128,9 +96,9 @@

By type

- +
- ` -} - +// renderWorklogSummary is the four-measure grid at the top, with the +// components of each measure on the row beneath -- the shape the +// ServiceNow report has always used. The third column differs by +// cadence: the daily counts who was not on leave, the weekly the mean +// number of people logging on a day. func renderWorklogSummary(r worklogreport.Report, daily bool) string { t := r.Totals - var b strings.Builder - b.WriteString(summaryRow("Total members", strconv.Itoa(t.Members))) - b.WriteString(summaryRow("Total issues", strconv.Itoa(t.Issues))) - b.WriteString(summaryRow("Total support time (hrs)", hrs(t.TotalHours))) - b.WriteString(summaryRow("Average support time (per person)", hrs(t.AvgHoursPerPerson))) + thirdLabel, thirdValue := "Average People Per Day", hrs(t.AveragePeoplePerDay) if daily { - b.WriteString(summaryRow("Non-leave members", days(t.NonLeaveMembers))) - b.WriteString(summaryRow("On leave", strconv.Itoa(t.OnLeaveMembers))) - } else { - b.WriteString(summaryRow("Average people per day", hrs(t.AveragePeoplePerDay))) + thirdLabel, thirdValue = "Non Leave Members", days(t.NonLeaveMembers) } - b.WriteString(summaryRow("Queries", strconv.Itoa(t.QueryIssues))) - b.WriteString(summaryRow("Incidents", strconv.Itoa(t.IncidentIssues))) - b.WriteString(summaryRow("Approved (hrs)", hrs(t.ApprovedHours))) - b.WriteString(summaryRow("Pending (hrs)", hrs(t.PendingHours))) + var b strings.Builder + b.WriteString(`
+ `) + values.WriteString(``) + } + return `
diff --git a/operations/csm-scheduled-tasks/internal/notify/worklogs_report.go b/operations/csm-scheduled-tasks/internal/notify/worklogs_report.go index 08efb265e4..ddd454f154 100644 --- a/operations/csm-scheduled-tasks/internal/notify/worklogs_report.go +++ b/operations/csm-scheduled-tasks/internal/notify/worklogs_report.go @@ -32,13 +32,66 @@ var worklogsReportTemplateRaw string var worklogsReportTemplate = bakeLogo(worklogsReportTemplateRaw) +// The layout follows the ServiceNow original -- a stat grid, then a card +// per measure holding one pill per team, then a section per team -- in +// WSO2's palette rather than ServiceNow's navy. Every figure it shows was +// already in the API response; nothing here needed a new field. const ( - cellLeft = `style="padding:7px 10px; border:1px solid #dfe3e6;"` - cellRight = `align="right" style="padding:7px 10px; border:1px solid #dfe3e6;"` - headLeft = `align="left" style="background-color:#f1f3f4; padding:8px 10px; border:1px solid #dfe3e6;"` - headRight = `align="right" style="background-color:#f1f3f4; padding:8px 10px; border:1px solid #dfe3e6;"` + cellLeft = `style="padding:5px 8px; border:1px solid #dfe3e6;"` + cellRight = `align="right" style="padding:5px 8px; border:1px solid #dfe3e6;"` + headLeft = `align="left" style="background-color:#f1f3f4; padding:6px 8px; border:1px solid #dfe3e6;"` + headRight = `align="right" style="background-color:#f1f3f4; padding:6px 8px; border:1px solid #dfe3e6;"` + + // statHead/statCell are the four-column summary grid; statSub/statSubCell + // the narrower row of components beneath it. + statHead = `style="background-color:#f1f3f5; text-align:center; padding:6px 8px; font-size:13px; color:#465868;"` + statCell = `style="background-color:#ffffff; text-align:center; padding:2px 8px 8px; font-size:22px; font-weight:700; color:#465868;"` + statSub = `style="background-color:#f8f9fa; text-align:center; padding:5px 8px; font-size:12px; color:#8a8f98;"` + statSubCell = `style="background-color:#ffffff; text-align:center; padding:2px 8px 7px; font-size:14px; color:#465868;"` ) +// pill is one labelled figure: the name above, the value below in WSO2 +// orange. ServiceNow used these for its per-team and per-type rows. +// +// A pill is a pair of cells in a shared two-row table rather than a table +// of its own -- that is what keeps every orange bar on one line when a +// team name wraps, which "Americas CS Team - Integration" always does. +type pillCell struct{ label, value string } + +// pillRow lays the pills out side by side at equal width. +func pillRow(cells []pillCell) string { + if len(cells) == 0 { + return "" + } + var labels, values strings.Builder + for _, c := range cells { + labels.WriteString(`` + + `
` + + escapeHTML(c.label) + `
` + + `
` + + escapeHTML(c.value) + `
` + + `` + labels.String() + `` + values.String() + `
` +} + +// breakdownCard is one measure -- Members, Issues, Approved/Pending/Total +// time -- with its headline figure and the per-team (and, where the +// original had one, per-type) pills beside it. +func breakdownCard(title, total string, rows ...string) string { + var body strings.Builder + for _, r := range rows { + body.WriteString(r) + } + return `` + + `` + + `` + + `
` + + `
` + escapeHTML(title) + `
` + + `
` + escapeHTML(total) + `
` + body.String() + `
` +} + // WorklogsReportSubject is the mail's subject line, in the same shape the // delivered reports use: "[2026-10-18] CS Worklogs Daily Report" and // "[2026-10-05 - 2026-10-11] CS Worklogs Weekly Report". @@ -88,9 +141,8 @@ func RenderWorklogsReport(r worklogreport.Report, caseLinkBase string) string { replacer := strings.NewReplacer( "", cadenceLabel, "", escapeHTML(rangeLabel), - "", renderWorklogSummary(r, daily), - "", renderWorklogTeamSummary(r), - "", renderWorklogTypes(r), + "", renderWorklogSummary(r, daily), + "", renderWorklogBreakdowns(r), "", renderWorklogTeamSections(r, daily, caseLinkBase), "", strconv.Itoa(time.Now().Year()), ) @@ -108,125 +160,167 @@ func days(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) } -func summaryRow(label, value string) string { - return `
` + escapeHTML(label) + `` + escapeHTML(value) + `
`) + + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
Total MembersTotal IssuesTotal Support Time (Hrs)Average Support Time (per person)
` + strconv.Itoa(t.Members) + `` + strconv.Itoa(t.Issues) + `` + hrs(t.TotalHours) + `` + hrs(t.AvgHoursPerPerson) + `
` + escapeHTML(thirdLabel) + `QueriesIncidentsApprovedPendingApprovedPending
` + escapeHTML(thirdValue) + `` + strconv.Itoa(t.QueryIssues) + `` + strconv.Itoa(t.IncidentIssues) + `` + hrs(t.ApprovedHours) + `` + hrs(t.PendingHours) + `` + hrs(t.AvgApprovedHoursPerPerson) + `` + hrs(t.AvgPendingHoursPerPerson) + `
`) return b.String() } -func renderWorklogTeamSummary(r worklogreport.Report) string { - if len(r.Teams) == 0 { - return `
No teams logged time in this period.
` + escapeHTML(t.TeamName) + `` + strconv.Itoa(t.Members) + `` + strconv.Itoa(t.Issues) + `` + hrs(t.ApprovedHours) + `` + hrs(t.PendingHours) + `` + hrs(t.TotalHours) + `` + hrs(t.AvgTotalHoursPerPerson) + `
No time logged in this period.
` + escapeHTML(t.Label) + `` + strconv.Itoa(t.Issues) + `` + hrs(t.ApprovedHours) + `` + hrs(t.PendingHours) + `` + hrs(t.TotalHours) + `
`) - b.WriteString(``) - if daily { - b.WriteString(``) - } else { - b.WriteString(``) + b.WriteString(`
DeveloperLeaveWorked days
`) + b.WriteString(``) + for _, c := range []struct{ v, l string }{ + {strconv.Itoa(t.Members), "Members"}, + {strconv.Itoa(t.Issues), "Issues"}, + {hrs(t.ApprovedHours), "Approved Hrs."}, + {hrs(t.PendingHours), "Pending Hrs."}, + {hrs(t.AvgApprovedHoursPerPerson), "Avg. Approved Hrs/person"}, + {hrs(t.AvgTotalHoursPerPerson), "Avg. Total Hrs/person"}, + } { + b.WriteString(``) } - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) + b.WriteString(`
TEAM
` + + `
` + escapeHTML(t.TeamName) + `
` + + escapeHTML(c.v) + `
` + escapeHTML(c.l) + `
Approved #Approved hrsPending #Pending hrs
`) + + secondLabel, lastLabel := "Worked Days", "Avg. Hrs/Day" if daily { - b.WriteString(`
DisapprovedAvg hrs/dayIssues
`) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) b.WriteString(``) if len(t.Developers) == 0 { - b.WriteString(``) + b.WriteString(``) } for _, d := range t.Developers { b.WriteString(``) - b.WriteString(``) + b.WriteString(``) if daily { - b.WriteString(``) + b.WriteString(``) } else { - b.WriteString(``) - } - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - if daily { - b.WriteString(``) - } else { - b.WriteString(``) + b.WriteString(``) } + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) links := make([]string, 0, len(d.Issues)) for _, iss := range d.Issues { links = append(links, caseLink(caseLinkBase, iss)) } - b.WriteString(``) + b.WriteString(``) + if daily { + b.WriteString(``) + } else { + b.WriteString(``) + } b.WriteString(``) } b.WriteString(`
Developer` + escapeHTML(secondLabel) + `Approved IssuesPending IssuesIssues` + escapeHTML(lastLabel) + `
Issues #Time (Hours)Issues #Time (Hours)
No members.
No members.
` + escapeHTML(d.Email) + `` + escapeHTML(d.Email) + `` + escapeHTML(dashIfEmpty(d.Leave)) + `` + escapeHTML(dashIfEmpty(d.Leave)) + `` + days(d.WorkedDays) + `` + strconv.Itoa(d.ApprovedIssues) + `` + hrs(d.ApprovedHours) + `` + strconv.Itoa(d.PendingIssues) + `` + hrs(d.PendingHours) + `` + strconv.Itoa(d.DisapprovedEntries) + `` + hrs(d.AvgTimePerDay) + `` + days(d.WorkedDays) + `` + strconv.Itoa(d.ApprovedIssues) + `` + hrs(d.ApprovedHours) + `` + strconv.Itoa(d.PendingIssues) + `` + hrs(d.PendingHours) + `` + strings.Join(links, " ") + `` + strings.Join(links, " ") + `` + strconv.Itoa(d.DisapprovedEntries) + `` + hrs(d.AvgTimePerDay) + `
`)