From 8ba113842fd0014df972a759a6611cbcfa342d7f Mon Sep 17 00:00:00 2001 From: "Joshua Uhalt, Ph.D." Date: Mon, 28 Sep 2026 08:48:56 -0400 Subject: [PATCH 1/6] Add Insights, journal history, and shareable reports Route statistics (#30) come from recorded dates only: per submission (first decision, revision rounds, outcome, time at the journal), per manuscript (journals, time to acceptance and publication), and library medians with their sample sizes. Missing or inconsistent dates are left out, never estimated. The Insights page (Ctrl+5; Import & Export moves to Ctrl+6) shows the medians, Your Journals (#62), grouped by Journal Library record or exact name, and Your Routes. The Journals page shows the selected journal's history in one line. Pipeline and route reports (#63, #30) are self-contained web pages, previewed before saving, that never read notes, correspondence, reviewer comments, file paths, or contact details. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 + ManuscriptPipeline/Controls/RailItems.vb | 7 + ManuscriptPipeline/Form1.Insights.vb | 372 ++++++++++++++++ ManuscriptPipeline/Form1.ManuscriptPage.vb | 2 + ManuscriptPipeline/Form1.Reports.vb | 60 +++ ManuscriptPipeline/Form1.Shell.vb | 27 +- ManuscriptPipeline/Form1.vb | 5 +- .../Forms/JournalLibraryForm.vb | 51 ++- ManuscriptPipeline/Forms/ReportForm.vb | 230 ++++++++++ ManuscriptPipeline/Services/ReportService.vb | 232 ++++++++++ .../Services/RouteAnalyticsService.vb | 408 ++++++++++++++++++ PaperRoute.Tests/DashboardShelfLayoutTests.vb | 6 +- PaperRoute.Tests/ManuscriptPageTests.vb | 69 ++- PaperRoute.Tests/ReportServiceTests.vb | 83 ++++ PaperRoute.Tests/RouteAnalyticsTests.vb | 139 ++++++ docs/USER_GUIDE.md | 25 +- 16 files changed, 1694 insertions(+), 36 deletions(-) create mode 100644 ManuscriptPipeline/Form1.Insights.vb create mode 100644 ManuscriptPipeline/Form1.Reports.vb create mode 100644 ManuscriptPipeline/Forms/ReportForm.vb create mode 100644 ManuscriptPipeline/Services/ReportService.vb create mode 100644 ManuscriptPipeline/Services/RouteAnalyticsService.vb create mode 100644 PaperRoute.Tests/ReportServiceTests.vb create mode 100644 PaperRoute.Tests/RouteAnalyticsTests.vb diff --git a/CHANGELOG.md b/CHANGELOG.md index d8eeba3..2d8de33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to PaperRoute Tracker will be documented here. +## [Unreleased] — v0.8 Insights & Reports + +Development is tracked in the [v0.8 milestone](https://github.com/JUhalt/PaperRoute-Tracker/milestone/10). + +### Added + +- An **Insights** page (Ctrl+5) with route statistics from your own records ([#30](https://github.com/JUhalt/PaperRoute-Tracker/issues/30)): median time to a first decision and from first submission to acceptance, each with its sample size, and **Your Routes**: journals, revision rounds, and days to acceptance and publication for every submitted manuscript. Missing dates are left out, never estimated. +- **Your Journals** ([#62](https://github.com/JUhalt/PaperRoute-Tracker/issues/62)): submissions, outcomes, and median days to a first decision and in review for each journal you have used, grouped by Journal Library record or exact name, never by guess. The Journals page shows the same history for the selected journal. +- **Pipeline Report...** and **Route Report...** ([#63](https://github.com/JUhalt/PaperRoute-Tracker/issues/63), [#30](https://github.com/JUhalt/PaperRoute-Tracker/issues/30)): self-contained web pages to print or save as PDF, previewed before saving. They never include notes, correspondence, reviewer comments, file paths, or contact details. + +### Changed + +- Import & Export moves to **Ctrl+6**; Insights takes Ctrl+5. + ## [Unreleased] — v0.7 Deadline Center v0.7.0-rc.1 is available on the Preview channel. Development is tracked in [PR #78](https://github.com/JUhalt/PaperRoute-Tracker/pull/78) and the [v0.7 milestone](https://github.com/JUhalt/PaperRoute-Tracker/milestone/9). diff --git a/ManuscriptPipeline/Controls/RailItems.vb b/ManuscriptPipeline/Controls/RailItems.vb index 7e25b2b..6a6b6cb 100644 --- a/ManuscriptPipeline/Controls/RailItems.vb +++ b/ManuscriptPipeline/Controls/RailItems.vb @@ -21,6 +21,7 @@ Namespace Controls FollowUp Checklist Publication + Insights End Enum ' Paints one left-rail item: an icon, its label, and an optional count. @@ -162,6 +163,12 @@ Namespace Controls Case RailGlyph.Checklist g.DrawRectangle(pen, Rectangle.Round(r(3, 3, 10, 10))) g.DrawLines(pen, {p(5.5F, 8.2F), p(7.3F, 10), p(10.7F, 6)}) + Case RailGlyph.Insights + ' Three bars on a baseline. + g.DrawLine(pen, p(2, 13.5F), p(14, 13.5F)) + g.DrawRectangle(pen, Rectangle.Round(r(3.5F, 8, 2, 5))) + g.DrawRectangle(pen, Rectangle.Round(r(7, 3, 2, 10))) + g.DrawRectangle(pen, Rectangle.Round(r(10.5F, 6, 2, 7))) Case RailGlyph.Publication ' An open book. g.DrawLines(pen, {p(8, 4.5F), p(3, 3.5F), p(3, 12), p(8, 13)}) diff --git a/ManuscriptPipeline/Form1.Insights.vb b/ManuscriptPipeline/Form1.Insights.vb new file mode 100644 index 0000000..26d8a86 --- /dev/null +++ b/ManuscriptPipeline/Form1.Insights.vb @@ -0,0 +1,372 @@ +Imports System +Imports System.Collections.Generic +Imports System.Drawing +Imports System.Globalization +Imports System.Linq +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' The Insights page (#30, #62): how your work has moved through journals, +' from your own records. Calculated on this computer each time the page is +' shown; nothing here changes a record. +Partial Public Class Form1 + + Private insightsContent As Panel = Nothing + Private insightsGrid As DataGridView = Nothing + Private insightsJournal As JournalHistory = Nothing + Private tabInsightsJournals As ShelfTabButton = Nothing + Private tabInsightsRoutes As ShelfTabButton = Nothing + Private insightsTiles As FlowLayoutPanel = Nothing + Private insightsScope As LinkLabel = Nothing + + + Private Function BuildInsightsPage() As Control + + insightsJournal = Nothing + + Dim btnReport As New ActionButton With { + .Text = "Pipeline Report...", + .Width = GetResponsiveButtonWidth("Pipeline Report...", 140), + .Height = GetResponsiveButtonHeight(34), + .AccessibleDescription = "Saves a read-only summary of the pipeline to share with a supervisor or coauthor." + } + AddHandler btnReport.Click, AddressOf SavePipelineReport + + Dim frame As TableLayoutPanel = CreatePageFrame( + "Insights", + "How your work has moved through journals, from your own PaperRoute records. Calculated on this computer; missing dates are left out, never estimated.", + btnReport) + + Dim dpi As Integer = DeviceDpi + Dim body As New TableLayoutPanel With { + .Dock = DockStyle.Fill, + .ColumnCount = 1, + .RowCount = 5, + .Margin = New Padding(0), + .BackColor = UiTheme.BoardBackground() + } + body.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100.0F)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + body.RowStyles.Add(New RowStyle(SizeType.Percent, 100.0F)) + body.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + + insightsTiles = New FlowLayoutPanel With { + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .WrapContents = True, + .Dock = DockStyle.Top, + .Margin = New Padding(0, 0, 0, UiTheme.Px(10, dpi)), + .BackColor = UiTheme.BoardBackground(), + .AccessibleName = "At a glance" + } + body.Controls.Add(insightsTiles, 0, 0) + + Dim views As New FlowLayoutPanel With { + .Dock = DockStyle.Top, + .AutoSize = True, + .AutoSizeMode = AutoSizeMode.GrowAndShrink, + .WrapContents = False, + .Margin = New Padding(0, 0, 0, UiTheme.Px(6, dpi)), + .BackColor = UiTheme.BoardBackground() + } + AddHandler views.Paint, + Sub(sender, e) + Using line As New Pen(UiTheme.CardBorder()) + e.Graphics.DrawLine(line, 0, views.Height - 1, views.Width, views.Height - 1) + End Using + End Sub + tabInsightsJournals = New ShelfTabButton() With {.Text = "Your Journals"} + tabInsightsRoutes = New ShelfTabButton() With {.Text = "Your Routes"} + views.Controls.Add(tabInsightsJournals) + views.Controls.Add(tabInsightsRoutes) + body.Controls.Add(views, 0, 1) + + ' "Showing manuscripts sent to X · Show all" while a journal is chosen. + insightsScope = New LinkLabel With { + .AutoSize = True, + .UseMnemonic = False, + .Visible = False, + .LinkColor = UiTheme.AccentColor(), + .ActiveLinkColor = UiTheme.AccentColor(), + .VisitedLinkColor = UiTheme.AccentColor(), + .ForeColor = UiTheme.SecondaryText(), + .Margin = New Padding(0, 0, 0, UiTheme.Px(6, dpi)) + } + AddHandler insightsScope.LinkClicked, + Sub(sender, e) + insightsJournal = Nothing + FillInsights() + End Sub + body.Controls.Add(insightsScope, 0, 2) + + insightsContent = New Panel With {.Dock = DockStyle.Fill, .Margin = New Padding(0), .BackColor = UiTheme.BoardBackground()} + body.Controls.Add(insightsContent, 0, 3) + + body.Controls.Add(New Label With { + .Text = "Days are calendar days between recorded dates. A first decision is the earliest decision recorded for a submission; " & + "review time leaves out desk rejections. Journals group by their Journal Library record or exact name, never by a guess.", + .AutoSize = True, + .UseMnemonic = False, + .ForeColor = UiTheme.SecondaryText(), + .Margin = New Padding(0, UiTheme.Px(8, dpi), 0, 0) + }, 0, 4) + AddHandler body.SizeChanged, + Sub(sender, e) + For Each note As Label In body.Controls.OfType(Of Label)() + note.MaximumSize = New Size(Math.Max(UiTheme.Px(300, dpi), body.ClientSize.Width - UiTheme.Px(8, dpi)), 0) + Next + End Sub + + frame.Controls.Add(body, 0, 2) + + tabInsightsJournals.Checked = True + AddHandler tabInsightsJournals.CheckedChanged, Sub(sender, e) If tabInsightsJournals.Checked Then FillInsights() + AddHandler tabInsightsRoutes.CheckedChanged, Sub(sender, e) If tabInsightsRoutes.Checked Then FillInsights() + + FillInsights() + Return frame + + End Function + + + Private Function CurrentJournalLibrary() As IEnumerable(Of JournalRecord) + Return If(authorLibrary?.Journals, New List(Of JournalRecord)()) + End Function + + + Private Sub FillInsights() + + If insightsContent Is Nothing OrElse insightsContent.IsDisposed Then Return + + Dim statistics As LibraryStatistics = RouteAnalyticsService.ForLibrary(manuscripts, DateTime.Today, CurrentJournalLibrary()) + + ' A chosen journal may no longer exist after an edit. + If insightsJournal IsNot Nothing Then + Dim name As String = insightsJournal.JournalName + insightsJournal = statistics.Journals.FirstOrDefault(Function(item) item.JournalName = name) + End If + + FillInsightTiles(statistics) + + tabInsightsJournals.Text = "Your Journals (" & statistics.Journals.Count.ToString(CultureInfo.CurrentCulture) & ")" + tabInsightsRoutes.Text = "Your Routes (" & statistics.Manuscripts.Where(Function(item) item.Submissions.Count > 0).Count().ToString(CultureInfo.CurrentCulture) & ")" + + For Each child As Control In insightsContent.Controls.Cast(Of Control)().ToList() + insightsContent.Controls.Remove(child) + child.Dispose() + Next + + If tabInsightsRoutes.Checked Then + insightsGrid = CreateRoutesGrid(statistics) + insightsScope.Visible = insightsJournal IsNot Nothing + If insightsJournal IsNot Nothing Then + insightsScope.Text = "Showing manuscripts sent to " & insightsJournal.JournalName & " · Show all" + insightsScope.LinkArea = New LinkArea(insightsScope.Text.Length - 8, 8) + End If + Else + insightsGrid = CreateJournalsGrid(statistics) + insightsScope.Visible = False + End If + + insightsContent.Controls.Add(insightsGrid) + + End Sub + + + Private Sub FillInsightTiles(statistics As LibraryStatistics) + + insightsTiles.SuspendLayout() + For Each child As Control In insightsTiles.Controls.Cast(Of Control)().ToList() + insightsTiles.Controls.Remove(child) + child.Dispose() + Next + + Dim published As Integer = manuscripts.Where(Function(item) item.Location = ManuscriptLocation.Published).Count() + Dim submissions As Integer = statistics.Submissions.Count() + + insightsTiles.Controls.Add(CreateStatTile(manuscripts.Count.ToString(CultureInfo.CurrentCulture), + If(manuscripts.Count = 1, "manuscript", "manuscripts") & " · " & published.ToString(CultureInfo.CurrentCulture) & " published", UiTheme.PrimaryText())) + insightsTiles.Controls.Add(CreateStatTile(submissions.ToString(CultureInfo.CurrentCulture), + If(submissions = 1, "submission", "submissions") & " · " & statistics.CountOutcome(SubmissionOutcome.Accepted).ToString(CultureInfo.CurrentCulture) & " accepted", UiTheme.PrimaryText())) + insightsTiles.Controls.Add(CreateStatTile(DaysText(statistics.MedianDaysToFirstDecision), + "median to a first decision" & SampleText(statistics.MedianDaysToFirstDecision), UiTheme.AccentColor())) + insightsTiles.Controls.Add(CreateStatTile(DaysText(statistics.MedianDaysToAcceptance), + "median from first submission to acceptance" & SampleText(statistics.MedianDaysToAcceptance), UiTheme.AccentColor())) + + insightsTiles.ResumeLayout(True) + + End Sub + + + Private Shared Function DaysText(median As Median) As String + If Not median.HasValue Then Return "—" + Dim days As Double = median.Value.Value + Return days.ToString(If(days = Math.Floor(days), "0", "0.0"), CultureInfo.CurrentCulture) & If(days = 1, " day", " days") + End Function + + + Private Shared Function SampleText(median As Median) As String + Return If(median.SampleSize = 0, " (not recorded yet)", " (of " & median.SampleSize.ToString(CultureInfo.CurrentCulture) & ")") + End Function + + + ' A number over a caption, as on the Deadlines page. + Private Function CreateStatTile(value As String, caption As String, valueColor As Color) As Control + + Dim dpi As Integer = DeviceDpi + Dim panel As New RoundedPanel With { + .BackColor = UiTheme.CardBackground(), + .BorderColor = UiTheme.CardBorder(), + .BorderThickness = 1.0F, + .CornerRadius = UiTheme.Px(UiTheme.ControlRadius, dpi), + .Padding = New Padding(UiTheme.Px(12, dpi), UiTheme.Px(8, dpi), UiTheme.Px(12, dpi), UiTheme.Px(8, dpi)), + .Margin = New Padding(0, 0, UiTheme.Px(10, dpi), UiTheme.Px(6, dpi)), + .AccessibleName = value & " " & caption, + .AccessibleRole = AccessibleRole.StaticText + } + Dim number As New Label With { + .AutoSize = True, + .Text = value, + .UseMnemonic = False, + .Font = New Font(Me.Font.FontFamily, Me.Font.SizeInPoints * 1.45F, FontStyle.Bold), + .ForeColor = valueColor, + .BackColor = UiTheme.CardBackground(), + .Location = New Point(panel.Padding.Left, panel.Padding.Top) + } + Dim text As New Label With { + .AutoSize = True, + .Text = caption, + .UseMnemonic = False, + .ForeColor = UiTheme.SecondaryText(), + .BackColor = UiTheme.CardBackground() + } + panel.Controls.Add(number) + panel.Controls.Add(text) + text.Location = New Point(panel.Padding.Left, number.Bottom) + panel.Size = New Size( + Math.Max(UiTheme.Px(150, dpi), Math.Max(number.PreferredWidth, TextRenderer.MeasureText(caption, Me.Font).Width) + panel.Padding.Horizontal), + text.Bottom + panel.Padding.Bottom) + Return panel + + End Function + + + Private Function CreateJournalsGrid(statistics As LibraryStatistics) As DataGridView + + Dim grid As DataGridView = CreatePageGrid("Your journals. Press Enter to see the manuscripts sent to the selected journal.") + Dim dpi As Integer = DeviceDpi + + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Journal", .HeaderText = "Journal", .AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, .FillWeight = 200, .MinimumWidth = UiTheme.Px(200, dpi)}) + For Each column In {("Submissions", "Submissions"), ("Accepted", "Accepted"), ("Revisions", "Asked to revise"), + ("Rejected", "Rejected after review"), ("Desk", "Desk rejected"), ("FirstDecision", "Median days to first decision"), + ("Review", "Median days in review")} + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = column.Item1, .HeaderText = column.Item2, .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells, .ValueType = GetType(Double)}) + grid.Columns(column.Item1).DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight + Next + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Last", .HeaderText = "Last submitted", .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells, .ValueType = GetType(DateTime)}) + grid.Columns("Last").DefaultCellStyle.Format = "d" + For Each column As DataGridViewColumn In grid.Columns + column.SortMode = DataGridViewColumnSortMode.Automatic + Next + + For Each history As JournalHistory In statistics.Journals + Dim rejected As Integer = history.CountOutcome(SubmissionOutcome.RejectedAfterReview) + history.CountOutcome(SubmissionOutcome.Rejected) + Dim index As Integer = grid.Rows.Add( + history.JournalName, + CDbl(history.Count), + CDbl(history.CountOutcome(SubmissionOutcome.Accepted)), + CDbl(history.WithRevisions), + CDbl(rejected), + CDbl(history.CountOutcome(SubmissionOutcome.DeskRejected)), + MedianCell(history.MedianDaysToFirstDecision), + MedianCell(history.MedianReviewDays), + history.LastSubmitted) + grid.Rows(index).Tag = history + Next + + Dim openJournal As Action(Of DataGridViewRow) = + Sub(row) + insightsJournal = TryCast(row?.Tag, JournalHistory) + If insightsJournal IsNot Nothing Then tabInsightsRoutes.Checked = True + End Sub + AddHandler grid.CellDoubleClick, Sub(sender, e) If e.RowIndex >= 0 Then openJournal(grid.Rows(e.RowIndex)) + AddHandler grid.KeyDown, + Sub(sender, e) + If e.KeyCode = Keys.Enter AndAlso grid.CurrentRow IsNot Nothing Then + e.Handled = True + openJournal(grid.CurrentRow) + End If + End Sub + + EmptyHint.Attach(grid, Function() "No submissions recorded yet. Record one on a manuscript's Submissions tab, and your history with each journal appears here.") + Return grid + + End Function + + + Private Function CreateRoutesGrid(statistics As LibraryStatistics) As DataGridView + + Dim grid As DataGridView = CreatePageGrid("Your routes. Press Enter to open the selected manuscript.") + Dim dpi As Integer = DeviceDpi + + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Title", .HeaderText = "Manuscript", .AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, .FillWeight = 220, .MinimumWidth = UiTheme.Px(220, dpi)}) + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Stage", .HeaderText = "Stage", .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells}) + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Route", .HeaderText = "Route", .AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, .FillWeight = 80}) + For Each column In {("Journals", "Journals"), ("Rounds", "Revision rounds"), ("Acceptance", "Days to acceptance"), ("Publication", "Days to publication")} + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = column.Item1, .HeaderText = column.Item2, .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells, .ValueType = GetType(Double)}) + grid.Columns(column.Item1).DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight + Next + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "First", .HeaderText = "First submitted", .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells, .ValueType = GetType(DateTime)}) + grid.Columns("First").DefaultCellStyle.Format = "d" + For Each column As DataGridViewColumn In grid.Columns + column.SortMode = DataGridViewColumnSortMode.Automatic + Next + + Dim included As HashSet(Of Manuscript) = Nothing + If insightsJournal IsNot Nothing Then included = New HashSet(Of Manuscript)(insightsJournal.Submissions.Select(Function(item) item.Manuscript)) + + For Each route As ManuscriptStatistics In statistics.Manuscripts + If route.Submissions.Count = 0 Then Continue For + If included IsNot Nothing AndAlso Not included.Contains(route.Manuscript) Then Continue For + Dim index As Integer = grid.Rows.Add( + route.Manuscript.Title, + FormatStage(route.Manuscript.CurrentStage), + RouteSummaryService.Describe(route.Manuscript).Text, + CDbl(route.JournalCount), + CDbl(route.RevisionRounds), + If(route.DaysToAcceptance.HasValue, CObj(CDbl(route.DaysToAcceptance.Value)), Nothing), + If(route.DaysToPublication.HasValue, CObj(CDbl(route.DaysToPublication.Value)), Nothing), + route.FirstSubmitted.Value) + grid.Rows(index).Tag = route.Manuscript + Next + + AddHandler grid.CellDoubleClick, Sub(sender, e) If e.RowIndex >= 0 Then OpenInsightRoute(grid.Rows(e.RowIndex)) + AddHandler grid.KeyDown, + Sub(sender, e) + If e.KeyCode = Keys.Enter AndAlso grid.CurrentRow IsNot Nothing Then + e.Handled = True + OpenInsightRoute(grid.CurrentRow) + End If + End Sub + + EmptyHint.Attach(grid, Function() "No routes yet. A manuscript's route begins with its first recorded submission.") + Return grid + + End Function + + + Private Shared Function MedianCell(median As Median) As Object + Return If(median.HasValue, CObj(median.Value.Value), Nothing) + End Function + + + Private Sub OpenInsightRoute(row As DataGridViewRow) + Dim manuscript As Manuscript = TryCast(row?.Tag, Manuscript) + If manuscript IsNot Nothing Then OpenManuscript(manuscript) + End Sub + +End Class diff --git a/ManuscriptPipeline/Form1.ManuscriptPage.vb b/ManuscriptPipeline/Form1.ManuscriptPage.vb index bf86fd7..f9bd4e5 100644 --- a/ManuscriptPipeline/Form1.ManuscriptPage.vb +++ b/ManuscriptPipeline/Form1.ManuscriptPage.vb @@ -90,6 +90,7 @@ Partial Public Class Form1 Case WorkspacePage.Library : Return "Library" Case WorkspacePage.Journals : Return "Journals" Case WorkspacePage.Deadlines : Return "Deadlines" + Case WorkspacePage.Insights : Return "Insights" Case WorkspacePage.ImportExport : Return "Import & Export" Case Else : Return "Board" End Select @@ -260,6 +261,7 @@ Partial Public Class Form1 End Sub Dim pageMenu As New ContextMenuStrip() + pageMenu.Items.Add("Route Report...", Nothing, Sub(sender, e) SaveRouteReport(FindManuscript(currentManuscriptId))) pageMenu.Items.Add("Check for Publication...", Nothing, Sub(sender, e) CheckOpenManuscriptForPublication()) pageMenu.Items.Add(New ToolStripSeparator()) Dim deleteItem As ToolStripItem = diff --git a/ManuscriptPipeline/Form1.Reports.vb b/ManuscriptPipeline/Form1.Reports.vb new file mode 100644 index 0000000..8e6a6a6 --- /dev/null +++ b/ManuscriptPipeline/Form1.Reports.vb @@ -0,0 +1,60 @@ +Imports System +Imports System.Collections.Generic +Imports System.Globalization +Imports System.IO +Imports System.Linq +Imports System.Windows.Forms +Imports ManuscriptPipeline.Forms +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' Shareable reports (#30, #63): saved as self-contained web pages that +' print to PDF. Building one never changes a record. +Partial Public Class Form1 + + Private Function PreparedByName() As String + Dim self As AuthorRecord = If(authorLibrary?.Authors, New List(Of AuthorRecord)()). + FirstOrDefault(Function(author) author IsNot Nothing AndAlso author.IsMe) + Return If(self?.DisplayName, String.Empty) + End Function + + + Private Sub SavePipelineReport(sender As Object, e As EventArgs) + + If manuscripts.Count = 0 Then + MessageBox.Show(Me, "Add a manuscript before making a pipeline report.", "Pipeline Report", MessageBoxButtons.OK, MessageBoxIcon.Information) + Return + End If + + Dim choices As List(Of Manuscript) = manuscripts. + OrderBy(Function(item) item.Location). + ThenBy(Function(item) item.Title, StringComparer.CurrentCultureIgnoreCase).ToList() + Dim preparedBy As String = PreparedByName() + Dim today As DateTime = DateTime.Today + + Using dialog As New ReportForm("Pipeline Report", "PaperRoute-Pipeline-" & today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) & ".html", choices, + Function(chosen, deadlines) ReportService.PipelineReport(chosen, today, preparedBy, deadlines)) + If dialog.ShowDialog(Me) = DialogResult.OK Then + lblStatus.Text = "Saved the pipeline report to " & Path.GetFileName(dialog.SavedPath) & "." + End If + End Using + + End Sub + + + Private Sub SaveRouteReport(manuscript As Manuscript) + + If manuscript Is Nothing Then Return + Dim today As DateTime = DateTime.Today + Dim fileTitle As String = String.Concat(ReminderService.SafeManuscriptTitle(manuscript).Take(60).Where(Function(ch) Not Path.GetInvalidFileNameChars().Contains(ch))).Trim() + + Using dialog As New ReportForm("Route Report", fileTitle & " - Route.html", Nothing, + Function(chosen, deadlines) ReportService.RouteReport(manuscript, today)) + If dialog.ShowDialog(Me) = DialogResult.OK Then + lblStatus.Text = "Saved the route report to " & Path.GetFileName(dialog.SavedPath) & "." + End If + End Using + + End Sub + +End Class diff --git a/ManuscriptPipeline/Form1.Shell.vb b/ManuscriptPipeline/Form1.Shell.vb index 8d20763..63cf0f4 100644 --- a/ManuscriptPipeline/Form1.Shell.vb +++ b/ManuscriptPipeline/Form1.Shell.vb @@ -20,6 +20,7 @@ Partial Public Class Form1 Library Journals Deadlines + Insights ImportExport ' Not in the rail: a manuscript opened from another page. Manuscript @@ -193,6 +194,7 @@ Partial Public Class Form1 (WorkspacePage.Library, RailGlyph.Library, "Library"), (WorkspacePage.Journals, RailGlyph.Journals, "Journals"), (WorkspacePage.Deadlines, RailGlyph.Deadlines, "Deadlines"), + (WorkspacePage.Insights, RailGlyph.Insights, "Insights"), (WorkspacePage.ImportExport, RailGlyph.ImportExport, "Import & Export") } Dim page As WorkspacePage = entry.Item1 @@ -535,6 +537,8 @@ Partial Public Class Form1 libraryFilter = Nothing deadlinesList = Nothing deadlinesFilter = Nothing + insightsContent = Nothing + insightsGrid = Nothing ' Hosted editors save as they go. Refresh the board's copies, as the ' board did after their dialogs closed. @@ -570,6 +574,8 @@ Partial Public Class Form1 New JournalLibraryForm(manuscripts)) Case WorkspacePage.Deadlines view = BuildDeadlinesPage() + Case WorkspacePage.Insights + view = BuildInsightsPage() Case Else view = BuildImportExportPage() End Select @@ -861,9 +867,9 @@ Partial Public Class Form1 End Function - Private Sub ShowLibraryManuscripts() - - ClearLibraryContent() + ' A read-only, sortable table in the page style, shared by the Library + ' and Insights pages. + Private Function CreatePageGrid(accessibleName As String) As DataGridView Dim grid As New DataGridView With { .Dock = DockStyle.Fill, @@ -881,7 +887,7 @@ Partial Public Class Form1 .StandardTab = True, .BackgroundColor = UiTheme.CardBackground(), .GridColor = UiTheme.SubtleBorder(), - .AccessibleName = "All manuscripts. Press Enter to open the selected manuscript.", + .AccessibleName = accessibleName, .ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize } @@ -897,6 +903,17 @@ Partial Public Class Form1 grid.ColumnHeadersDefaultCellStyle.Padding = New Padding(UiTheme.Px(6, DeviceDpi), UiTheme.Px(6, DeviceDpi), UiTheme.Px(6, DeviceDpi), UiTheme.Px(6, DeviceDpi)) grid.RowTemplate.Height = TextRenderer.MeasureText("Ag", Me.Font).Height + UiTheme.Px(14, DeviceDpi) + Return grid + + End Function + + + Private Sub ShowLibraryManuscripts() + + ClearLibraryContent() + + Dim grid As DataGridView = CreatePageGrid("All manuscripts. Press Enter to open the selected manuscript.") + grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Title", .HeaderText = "Title", .AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, .FillWeight = 220, .MinimumWidth = UiTheme.Px(220, DeviceDpi)}) grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Stage", .HeaderText = "Stage", .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells}) grid.Columns.Add(New DataGridViewTextBoxColumn With {.Name = "Shelf", .HeaderText = "Shelf", .AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells}) @@ -1023,6 +1040,8 @@ Partial Public Class Form1 FillLibraryGrid() Case WorkspacePage.Deadlines FillDeadlines() + Case WorkspacePage.Insights + FillInsights() End Select End Sub diff --git a/ManuscriptPipeline/Form1.vb b/ManuscriptPipeline/Form1.vb index 5b3ddac..5814a8c 100644 --- a/ManuscriptPipeline/Form1.vb +++ b/ManuscriptPipeline/Form1.vb @@ -2179,7 +2179,7 @@ Public Class Form1 End If Return True - ' Ctrl+1 to Ctrl+5 open the rail's pages in order. + ' Ctrl+1 to Ctrl+6 open the rail's pages in order. Case Keys.Control Or Keys.D1, Keys.Control Or Keys.NumPad1 NavigateTo(WorkspacePage.Board) Return True @@ -2193,6 +2193,9 @@ Public Class Form1 NavigateTo(WorkspacePage.Deadlines) Return True Case Keys.Control Or Keys.D5, Keys.Control Or Keys.NumPad5 + NavigateTo(WorkspacePage.Insights) + Return True + Case Keys.Control Or Keys.D6, Keys.Control Or Keys.NumPad6 NavigateTo(WorkspacePage.ImportExport) Return True diff --git a/ManuscriptPipeline/Forms/JournalLibraryForm.vb b/ManuscriptPipeline/Forms/JournalLibraryForm.vb index be2fd02..f32fec4 100644 --- a/ManuscriptPipeline/Forms/JournalLibraryForm.vb +++ b/ManuscriptPipeline/Forms/JournalLibraryForm.vb @@ -363,34 +363,33 @@ Namespace Forms ). Count() - Dim submissionCount As Integer = - _manuscripts.Sum( - Function(item) - - If item.Submissions Is Nothing Then - Return 0 - End If - - Return item.Submissions. - Where( - Function(submission) - Return submission IsNot Nothing AndAlso - submission.JournalId.HasValue AndAlso - submission.JournalId.Value = selected.Id - End Function - ). - Count() + ' Your own history with the journal (#62), from recorded + ' submissions only: linked ones, and exact name matches. + Dim history As JournalHistory = + RouteAnalyticsService.ForLibrary(_manuscripts, DateTime.Today, _library.Journals).Journals. + FirstOrDefault(Function(item) item.JournalId.HasValue AndAlso item.JournalId.Value = selected.Id) + + Dim parts As New List(Of String) From { + selected.Name & " — target on " & targetCount.ToString() & If(targetCount = 1, " manuscript", " manuscripts") + } - End Function - ) + If history Is Nothing Then + parts.Add("no submissions recorded yet.") + Else + parts.Add(history.Count.ToString() & If(history.Count = 1, " submission", " submissions")) + For Each outcome In {(history.CountOutcome(SubmissionOutcome.Accepted), "accepted"), + (history.WithRevisions, "asked to revise"), + (history.CountOutcome(SubmissionOutcome.RejectedAfterReview) + history.CountOutcome(SubmissionOutcome.Rejected), "rejected"), + (history.CountOutcome(SubmissionOutcome.DeskRejected), "desk rejected")} + If outcome.Item1 > 0 Then parts.Add(outcome.Item1.ToString() & " " & outcome.Item2) + Next + If history.MedianDaysToFirstDecision.HasValue Then + parts.Add("median " & history.MedianDaysToFirstDecision.Value.Value.ToString("0.#", Globalization.CultureInfo.CurrentCulture) & " days to a first decision") + End If + parts.Add("last submitted " & history.LastSubmitted.ToString("MMM d, yyyy", Globalization.CultureInfo.CurrentCulture) & ".") + End If - lblInfo.Text = - selected.Name & - " — target on " & - targetCount.ToString() & - " manuscript(s); used by " & - submissionCount.ToString() & - " submission(s)." + lblInfo.Text = String.Join(" · ", parts) End Sub diff --git a/ManuscriptPipeline/Forms/ReportForm.vb b/ManuscriptPipeline/Forms/ReportForm.vb new file mode 100644 index 0000000..e75a86b --- /dev/null +++ b/ManuscriptPipeline/Forms/ReportForm.vb @@ -0,0 +1,230 @@ +Imports System +Imports System.Collections.Generic +Imports System.Diagnostics +Imports System.Drawing +Imports System.IO +Imports System.Linq +Imports System.Text +Imports System.Windows.Forms +Imports ManuscriptPipeline.Controls +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +Namespace Forms + + ' Previews a report exactly as it will be saved (#30, #63). For the + ' pipeline report, the user chooses which manuscripts it covers. + Friend Class ReportForm + Inherits Form + + Private ReadOnly _build As Func(Of List(Of Manuscript), Boolean, String) + Private ReadOnly _choices As List(Of Manuscript) + Private ReadOnly _fileName As String + + Private ReadOnly lstManuscripts As New CheckedListBox() + Private ReadOnly chkDeadlines As New CheckBox() + Private ReadOnly chkOpen As New CheckBox() + Private ReadOnly preview As New WebBrowser() + Private ReadOnly btnSave As New ActionButton() + Private ReadOnly previewTimer As New Timer With {.Interval = 250} + + ' Tests choose the file instead of a Save dialog. + Friend SavePathPrompt As Func(Of String) = Nothing + + + Friend Property SavedPath As String = String.Empty + + ' A report of the chosen manuscripts, with a list to choose them. + Public Sub New(title As String, fileName As String, choices As List(Of Manuscript), build As Func(Of List(Of Manuscript), Boolean, String)) + _build = build + _choices = choices + _fileName = fileName + BuildInterface(title) + End Sub + + Private Sub BuildInterface(title As String) + + Text = title + StartPosition = FormStartPosition.CenterParent + ShowInTaskbar = False + MinimizeBox = False + AutoScaleMode = AutoScaleMode.Dpi + Font = New Font("Segoe UI", 9.0F) + ClientSize = New Size(1120, 700) + MinimumSize = New Size(720, 480) + + Dim root As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = If(_choices Is Nothing, 1, 2), .RowCount = 2, .Padding = New Padding(16, 14, 16, 12)} + If _choices IsNot Nothing Then root.ColumnStyles.Add(New ColumnStyle(SizeType.Absolute, 280)) + root.ColumnStyles.Add(New ColumnStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) + root.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + + If _choices IsNot Nothing Then + Dim side As New TableLayoutPanel With {.Dock = DockStyle.Fill, .ColumnCount = 1, .RowCount = 4, .Margin = New Padding(0, 0, 12, 0)} + side.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + side.RowStyles.Add(New RowStyle(SizeType.Percent, 100)) + side.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + side.RowStyles.Add(New RowStyle(SizeType.AutoSize)) + side.Controls.Add(New Label With {.Text = "Include", .AutoSize = True, .Font = New Font(Font, FontStyle.Bold), .Margin = New Padding(0, 0, 0, 4)}, 0, 0) + + lstManuscripts.Dock = DockStyle.Fill + lstManuscripts.CheckOnClick = True + lstManuscripts.IntegralHeight = False + lstManuscripts.HorizontalScrollbar = True + lstManuscripts.AccessibleName = "Manuscripts in the report" + For Each manuscript As Manuscript In _choices + Dim index As Integer = lstManuscripts.Items.Add(ReminderService.SafeManuscriptTitle(manuscript)) + lstManuscripts.SetItemChecked(index, manuscript.Location = ManuscriptLocation.Pipeline) + Next + AddHandler lstManuscripts.ItemCheck, Sub(sender, e) SchedulePreview() + side.Controls.Add(lstManuscripts, 0, 1) + + chkDeadlines.Text = "Include the next deadline" + chkDeadlines.AutoSize = True + chkDeadlines.Checked = True + chkDeadlines.Margin = New Padding(0, 8, 0, 0) + AddHandler chkDeadlines.CheckedChanged, Sub(sender, e) SchedulePreview() + side.Controls.Add(chkDeadlines, 0, 2) + + side.Controls.Add(New Label With { + .Text = "The report never includes notes, correspondence, reviewer comments, files, or contact details. Reminders appear only as ""Reminder"".", + .AutoSize = True, + .MaximumSize = New Size(260, 0), + .ForeColor = SystemColors.GrayText, + .UseMnemonic = False, + .Margin = New Padding(0, 10, 0, 0) + }, 0, 3) + root.Controls.Add(side, 0, 0) + End If + + preview.Dock = DockStyle.Fill + preview.ScriptErrorsSuppressed = True + preview.IsWebBrowserContextMenuEnabled = False + preview.WebBrowserShortcutsEnabled = False + preview.AllowWebBrowserDrop = False + preview.AccessibleName = "Report preview" + ' The preview shows the report; it never leaves it. + AddHandler preview.Navigating, + Sub(sender, e) + If e.Url IsNot Nothing AndAlso e.Url.ToString() <> "about:blank" Then e.Cancel = True + End Sub + Dim frame As New Panel With {.Dock = DockStyle.Fill, .Padding = New Padding(1), .Margin = New Padding(0), .BackColor = UiTheme.CardBorder()} + frame.Controls.Add(preview) + root.Controls.Add(frame, If(_choices Is Nothing, 0, 1), 0) + + Dim buttons As New FlowLayoutPanel With {.AutoSize = True, .AutoSizeMode = AutoSizeMode.GrowAndShrink, .FlowDirection = FlowDirection.RightToLeft, .WrapContents = False, .Dock = DockStyle.Fill, .Margin = New Padding(0, 12, 0, 0)} + btnSave.Text = "Save Report..." + btnSave.Role = ActionButtonRole.Primary + btnSave.Height = 34 + btnSave.Width = 130 + AddHandler btnSave.Click, AddressOf SaveReport + Dim btnCancel As New ActionButton With {.Text = "Cancel", .Height = 34, .Width = 90, .DialogResult = DialogResult.Cancel, .Margin = New Padding(0, 0, 8, 0)} + chkOpen.Text = "Open in my browser after saving, to print or save as PDF" + chkOpen.AutoSize = True + chkOpen.Checked = True + chkOpen.Margin = New Padding(0, 8, 16, 0) + buttons.Controls.Add(btnSave) + buttons.Controls.Add(btnCancel) + buttons.Controls.Add(chkOpen) + root.Controls.Add(buttons, 0, 1) + root.SetColumnSpan(buttons, root.ColumnCount) + + Controls.Add(root) + AcceptButton = btnSave + CancelButton = btnCancel + + AddHandler previewTimer.Tick, + Sub(sender, e) + previewTimer.Stop() + RefreshPreview() + End Sub + + UiPolish.ApplyDialog(Me) + + End Sub + + + Protected Overrides Sub OnShown(e As EventArgs) + MyBase.OnShown(e) + RefreshPreview() + End Sub + + + Private Sub SchedulePreview() + previewTimer.Stop() + previewTimer.Start() + End Sub + + + Friend ReadOnly Property Chosen As List(Of Manuscript) + Get + If _choices Is Nothing Then Return Nothing + Return lstManuscripts.CheckedIndices.Cast(Of Integer)().Select(Function(index) _choices(index)).ToList() + End Get + End Property + + + Friend Function CurrentHtml() As String + Return _build(Chosen, chkDeadlines.Checked) + End Function + + + Private Sub RefreshPreview() + If IsDisposed Then Return + btnSave.Enabled = _choices Is Nothing OrElse lstManuscripts.CheckedIndices.Count > 0 + preview.DocumentText = CurrentHtml() + End Sub + + + Friend Sub SaveReport(sender As Object, e As EventArgs) + + Dim path As String + If SavePathPrompt IsNot Nothing Then + path = SavePathPrompt() + Else + Using picker As New SaveFileDialog With { + .Title = Text, + .Filter = "Web page (*.html)|*.html", + .DefaultExt = "html", + .AddExtension = True, + .OverwritePrompt = True, + .FileName = _fileName + } + If picker.ShowDialog(Me) <> DialogResult.OK Then Return + path = picker.FileName + End Using + End If + If String.IsNullOrWhiteSpace(path) Then Return + + Try + File.WriteAllText(path, CurrentHtml(), New UTF8Encoding(False)) + Catch ex As Exception When TypeOf ex Is IOException OrElse TypeOf ex Is UnauthorizedAccessException + MessageBox.Show(Me, "PaperRoute could not save the report." & Environment.NewLine & Environment.NewLine & ex.Message, + Text, MessageBoxButtons.OK, MessageBoxIcon.Error) + Return + End Try + + SavedPath = path + + If chkOpen.Checked AndAlso SavePathPrompt Is Nothing Then + Try + Process.Start(New ProcessStartInfo(path) With {.UseShellExecute = True}) + Catch ex As Exception + ' Saving succeeded; opening is a convenience. + End Try + End If + + DialogResult = DialogResult.OK + Close() + + End Sub + + + Protected Overrides Sub Dispose(disposing As Boolean) + If disposing Then previewTimer.Dispose() + MyBase.Dispose(disposing) + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/ReportService.vb b/ManuscriptPipeline/Services/ReportService.vb new file mode 100644 index 0000000..2e8e90d --- /dev/null +++ b/ManuscriptPipeline/Services/ReportService.vb @@ -0,0 +1,232 @@ +Imports System +Imports System.Collections.Generic +Imports System.Globalization +Imports System.Linq +Imports System.Net +Imports System.Text +Imports ManuscriptPipeline.Models + +Namespace Services + + ' Self-contained HTML reports (#30, #63) that open in any browser and + ' print to PDF. They are built only from titles, stages, journals, dates, + ' and decisions: notes, correspondence, reviewer comments and responses, + ' file paths, and contact details are never read, so they cannot leak. + Public NotInheritable Class ReportService + + Private Sub New() + End Sub + + + Public Shared Function StageName(stage As PaperStage) As String + Select Case stage + Case PaperStage.UnderReview : Return "Under review" + Case PaperStage.InPress : Return "In press" + Case Else : Return stage.ToString() + End Select + End Function + + + Public Shared Function ShelfName(location As ManuscriptLocation) As String + Select Case location + Case ManuscriptLocation.FileDrawer : Return "File Drawer" + Case ManuscriptLocation.Published : Return "Published" + Case Else : Return "Pipeline" + End Select + End Function + + + ' A pipeline status report for a supervisor, mentor, or coauthor. + Public Shared Function PipelineReport( + manuscripts As IEnumerable(Of Manuscript), + today As DateTime, + Optional preparedBy As String = "", + Optional includeDeadlines As Boolean = True + ) As String + + Dim chosen As List(Of Manuscript) = manuscripts.Where(Function(item) item IsNot Nothing).ToList() + Dim deadlines As List(Of DeadlineItem) = + If(includeDeadlines, DeadlineService.Build(chosen, today).Where(Function(item) item.DueDate.HasValue AndAlso item.Group <> DeadlineGroup.Done).ToList(), New List(Of DeadlineItem)()) + + Dim html As New StringBuilder() + OpenDocument(html, "Manuscript pipeline") + html.Append("

Manuscript pipeline

") + html.Append(Encode(today.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture))) + If Not String.IsNullOrWhiteSpace(preparedBy) Then html.Append(" · Prepared by ").Append(Encode(preparedBy.Trim())) + html.Append(" · ").Append(chosen.Count.ToString(CultureInfo.CurrentCulture)).Append(If(chosen.Count = 1, " manuscript", " manuscripts")) + html.Append("

") + + For Each location In {ManuscriptLocation.Pipeline, ManuscriptLocation.Published, ManuscriptLocation.FileDrawer} + Dim shelf As List(Of Manuscript) = chosen.Where(Function(item) item.Location = location). + OrderBy(Function(item) item.CurrentStage).ThenBy(Function(item) item.Title, StringComparer.CurrentCultureIgnoreCase).ToList() + If shelf.Count = 0 Then Continue For + + html.Append("

").Append(Encode(ShelfName(location))).Append("

") + html.Append("") + If location = ManuscriptLocation.Pipeline Then html.Append("") + If includeDeadlines AndAlso location = ManuscriptLocation.Pipeline Then html.Append("") + html.Append("") + + For Each manuscript As Manuscript In shelf + html.Append("") + html.Append("") + html.Append("") + html.Append("") + If location = ManuscriptLocation.Pipeline Then + Dim entered As DateTime = manuscript.StageEnteredDate.Date + html.Append("") + End If + If includeDeadlines AndAlso location = ManuscriptLocation.Pipeline Then + Dim nextDue As DeadlineItem = deadlines.Where(Function(item) item.ManuscriptId = manuscript.Id).OrderBy(Function(item) item.DueDate.Value).FirstOrDefault() + html.Append("") + End If + html.Append("") + Next + + html.Append("
ManuscriptStageJournalRouteIn stageNext deadline
").Append(Encode(ReminderService.SafeManuscriptTitle(manuscript))).Append("").Append(Encode(StageName(manuscript.CurrentStage))).Append("").Append(Encode(If(manuscript.TargetJournal, String.Empty).Trim())).Append("").Append(Encode(RouteSummaryService.Describe(manuscript).Text)).Append("") + If entered > DateTime.MinValue.Date AndAlso entered <= today.Date Then html.Append(DayCount((today.Date - entered).Days)) + html.Append("") + If nextDue IsNot Nothing Then + html.Append(Encode(nextDue.DueDate.Value.ToString("MMM d", CultureInfo.CurrentCulture))).Append(" · ").Append(Encode(DeadlineLabel(nextDue))) + End If + html.Append("
") + Next + + html.Append("
From PaperRoute records on ").Append(Encode(today.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture))) + html.Append(". Notes, correspondence, reviewer comments, and files are not included.
") + CloseDocument(html) + Return html.ToString() + + End Function + + + ' Custom reminder titles are the user's own words, so a shared report + ' names only the kind of deadline. + Private Shared Function DeadlineLabel(item As DeadlineItem) As String + Select Case item.Kind + Case DeadlineKind.Revision : Return "Revision due" + Case DeadlineKind.FollowUp : Return "Journal follow-up" + Case Else : Return "Reminder" + End Select + End Function + + + ' One manuscript's route: every submission, its decisions, and the + ' durations between recorded dates, with the definitions used. + Public Shared Function RouteReport(manuscript As Manuscript, today As DateTime) As String + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + + Dim statistics As ManuscriptStatistics = RouteAnalyticsService.DescribeManuscript(manuscript, today) + Dim html As New StringBuilder() + Dim title As String = ReminderService.SafeManuscriptTitle(manuscript) + + OpenDocument(html, title & " route") + html.Append("

").Append(Encode(title)).Append("

") + html.Append(Encode(StageName(manuscript.CurrentStage))).Append(" · ").Append(Encode(ShelfName(manuscript.Location))) + html.Append(" · Route as of ").Append(Encode(today.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture))).Append("

") + + html.Append("
") + Summary(html, "Journals", statistics.JournalCount.ToString(CultureInfo.CurrentCulture)) + Summary(html, "Revision rounds", statistics.RevisionRounds.ToString(CultureInfo.CurrentCulture)) + Summary(html, "First submitted", DateText(statistics.FirstSubmitted)) + Summary(html, "First submission to acceptance", If(statistics.DaysToAcceptance.HasValue, DayCount(statistics.DaysToAcceptance.Value), "Not recorded")) + Summary(html, "First submission to publication", If(statistics.DaysToPublication.HasValue, DayCount(statistics.DaysToPublication.Value), "Not recorded")) + html.Append("
") + + If statistics.Submissions.Count = 0 Then + html.Append("

No submissions are recorded yet.

") + Else + html.Append("

Submissions

") + Dim number As Integer = 0 + For Each submission As SubmissionStatistics In statistics.Submissions + number += 1 + Dim decisions As String = String.Join("; ", + If(submission.Submission.Decisions, New List(Of EditorialDecisionEvent)()). + Where(Function(item) item IsNot Nothing AndAlso item.Decision <> EditorialDecision.None). + OrderBy(Function(item) item.DecisionDate). + Select(Function(item) EditorialDecisionDisplayService.Format(item.Decision) & ", " & item.DecisionDate.ToString("MMM d, yyyy", CultureInfo.CurrentCulture))) + html.Append("") + html.Append("") + html.Append("") + html.Append("") + html.Append("") + html.Append("") + html.Append("") + Next + html.Append("
#JournalSubmittedDecisionsFirst decisionOutcomeAt the journal
").Append(number.ToString(CultureInfo.CurrentCulture)).Append("").Append(Encode(If(submission.JournalName.Length > 0, submission.JournalName, "(No journal recorded)"))).Append("").Append(Encode(submission.SubmittedDate.ToString("MMM d, yyyy", CultureInfo.CurrentCulture))).Append("").Append(Encode(If(decisions.Length > 0, decisions, "None recorded"))).Append("").Append(If(submission.DaysToFirstDecision.HasValue, DayCount(submission.DaysToFirstDecision.Value), "—")).Append("").Append(Encode(OutcomeName(submission.Outcome))).Append("").Append(If(submission.DaysAtJournal.HasValue, DayCount(submission.DaysAtJournal.Value) & If(submission.Outcome = SubmissionOutcome.Open, " so far", ""), "—")).Append("
") + End If + + html.Append("

How this is calculated. Days are calendar days between dates recorded in PaperRoute. ") + html.Append("A first decision is the earliest decision recorded for a submission. An open submission counts to the report date. ") + html.Append("Missing or inconsistent dates are shown as not recorded, never estimated. Dates are when events happened as entered, not when they were typed in.

") + html.Append("

Notes, correspondence, reviewer comments, and files are not included.

") + CloseDocument(html) + Return html.ToString() + + End Function + + + Public Shared Function OutcomeName(outcome As SubmissionOutcome) As String + Select Case outcome + Case SubmissionOutcome.DeskRejected : Return "Desk rejected" + Case SubmissionOutcome.RejectedAfterReview : Return "Rejected after review" + Case SubmissionOutcome.Rejected : Return "Rejected" + Case SubmissionOutcome.Accepted : Return "Accepted" + Case SubmissionOutcome.Withdrawn : Return "Withdrawn" + Case Else : Return "Open" + End Select + End Function + + + Private Shared Sub Summary(html As StringBuilder, term As String, value As String) + html.Append("
").Append(Encode(term)).Append("
").Append(Encode(value)).Append("
") + End Sub + + + Private Shared Function DateText(value As DateTime?) As String + Return If(value.HasValue, value.Value.ToString("MMM d, yyyy", CultureInfo.CurrentCulture), "Not recorded") + End Function + + + Private Shared Function DayCount(days As Integer) As String + Return days.ToString(CultureInfo.CurrentCulture) & If(days = 1, " day", " days") + End Function + + + Private Shared Function Encode(value As String) As String + Return WebUtility.HtmlEncode(If(value, String.Empty)) + End Function + + + ' Print-friendly, and readable on screen in light or dark. + Private Shared Sub OpenDocument(html As StringBuilder, title As String) + html.Append("") + html.Append("") + html.Append("") + html.Append("") + html.Append("").Append(Encode(title)).Append("") + End Sub + + + Private Shared Sub CloseDocument(html As StringBuilder) + html.Append("") + End Sub + + End Class + +End Namespace diff --git a/ManuscriptPipeline/Services/RouteAnalyticsService.vb b/ManuscriptPipeline/Services/RouteAnalyticsService.vb new file mode 100644 index 0000000..7d2fa03 --- /dev/null +++ b/ManuscriptPipeline/Services/RouteAnalyticsService.vb @@ -0,0 +1,408 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports System.Text.RegularExpressions +Imports ManuscriptPipeline.Models + +Namespace Services + + ' How a journal submission ended, from its latest recorded decision. + Public Enum SubmissionOutcome + ' No closing decision yet, including a revision in progress. + Open + DeskRejected + RejectedAfterReview + ' Rejected without saying whether the paper was reviewed. + Rejected + Accepted + Withdrawn + End Enum + + + Public NotInheritable Class SubmissionStatistics + + Public Property Submission As JournalSubmission + + Public Property JournalName As String = String.Empty + + Public Property SubmittedDate As DateTime + + ' The first recorded decision, and how long it took. + Public Property FirstDecision As EditorialDecision? = Nothing + + Public Property DaysToFirstDecision As Integer? = Nothing + + ' Revision requests (major, minor, or revise and resubmit). + Public Property RevisionRounds As Integer + + Public Property Outcome As SubmissionOutcome + + Public Property ClosedDate As DateTime? = Nothing + + ' Submitted to closed, or to today while open. + Public Property DaysAtJournal As Integer? = Nothing + + ' True when the first decision was a review outcome; Nothing when the + ' record does not say (no decision, withdrawn, or "rejected"). + Public Property WentToReview As Boolean? = Nothing + + End Class + + + Public NotInheritable Class ManuscriptStatistics + + Public Property Manuscript As Manuscript + + ' Oldest first. + Public Property Submissions As New List(Of SubmissionStatistics)() + + Public Property JournalCount As Integer + + Public Property FirstSubmitted As DateTime? = Nothing + + Public Property AcceptedDate As DateTime? = Nothing + + Public Property PublishedDate As DateTime? = Nothing + + Public Property DaysToAcceptance As Integer? = Nothing + + Public Property DaysToPublication As Integer? = Nothing + + Public ReadOnly Property RevisionRounds As Integer + Get + Return Submissions.Sum(Function(item) item.RevisionRounds) + End Get + End Property + + End Class + + + ' Your own history with one journal (#62): personal records, not + ' official journal statistics. + Public NotInheritable Class JournalHistory + + Public Property JournalName As String = String.Empty + + Public Property JournalId As Guid? = Nothing + + Public Property Submissions As New List(Of (Manuscript As Manuscript, Statistics As SubmissionStatistics))() + + Public ReadOnly Property Count As Integer + Get + Return Submissions.Count + End Get + End Property + + Public Function CountOutcome(outcome As SubmissionOutcome) As Integer + Return Submissions.Where(Function(item) item.Statistics.Outcome = outcome).Count() + End Function + + ' Submissions that were asked for at least one revision. + Public ReadOnly Property WithRevisions As Integer + Get + Return Submissions.Where(Function(item) item.Statistics.RevisionRounds > 0).Count() + End Get + End Property + + Public Property MedianDaysToFirstDecision As Median + + Public Property MedianReviewDays As Median + + Public ReadOnly Property LastSubmitted As DateTime + Get + Return Submissions.Max(Function(item) item.Statistics.SubmittedDate) + End Get + End Property + + End Class + + + ' A median and how many records it is based on. + Public Structure Median + + Public ReadOnly Value As Double? + Public ReadOnly SampleSize As Integer + + Public Sub New(value As Double?, sampleSize As Integer) + Me.Value = value + Me.SampleSize = sampleSize + End Sub + + Public ReadOnly Property HasValue As Boolean + Get + Return Value.HasValue + End Get + End Property + + End Structure + + + Public NotInheritable Class LibraryStatistics + + Public Property Manuscripts As New List(Of ManuscriptStatistics)() + + Public Property Journals As New List(Of JournalHistory)() + + Public ReadOnly Property Submissions As IEnumerable(Of SubmissionStatistics) + Get + Return Manuscripts.SelectMany(Function(item) item.Submissions) + End Get + End Property + + Public Function CountOutcome(outcome As SubmissionOutcome) As Integer + Return Submissions.Where(Function(item) item.Outcome = outcome).Count() + End Function + + Public Property MedianDaysToFirstDecision As Median + + Public Property MedianDaysToDeskRejection As Median + + Public Property MedianReviewDays As Median + + Public Property MedianDaysToAcceptance As Median + + Public Property MedianDaysToPublication As Median + + End Class + + + ' Route statistics (#30) from stored history only. A missing or + ' inconsistent date leaves a value out; nothing is estimated, and + ' nothing here changes a record. + Public NotInheritable Class RouteAnalyticsService + + Private Sub New() + End Sub + + + Public Shared Function DescribeSubmission(submission As JournalSubmission, today As DateTime) As SubmissionStatistics + + If submission Is Nothing Then Throw New ArgumentNullException(NameOf(submission)) + + Dim decisions As List(Of EditorialDecisionEvent) = + If(submission.Decisions, New List(Of EditorialDecisionEvent)()). + Where(Function(item) item IsNot Nothing AndAlso item.Decision <> EditorialDecision.None). + OrderBy(Function(item) item.DecisionDate).ToList() + + Dim submitted As DateTime = submission.SubmittedDate.Date + Dim statistics As New SubmissionStatistics With { + .Submission = submission, + .JournalName = If(submission.JournalName, String.Empty).Trim(), + .SubmittedDate = submitted, + .RevisionRounds = decisions.Where(Function(item) IsRevision(item.Decision)).Count() + } + + If decisions.Count > 0 Then + Dim first As EditorialDecisionEvent = decisions(0) + statistics.FirstDecision = first.Decision + statistics.DaysToFirstDecision = DaysBetween(submitted, first.DecisionDate) + Select Case first.Decision + Case EditorialDecision.DeskRejected + statistics.WentToReview = False + Case EditorialDecision.RejectedAfterReview, EditorialDecision.MajorRevision, EditorialDecision.MinorRevision, + EditorialDecision.ReviseAndResubmit, EditorialDecision.Accepted + statistics.WentToReview = True + End Select + End If + + Dim latest As EditorialDecisionEvent = decisions.LastOrDefault() + statistics.Outcome = OutcomeOf(If(latest Is Nothing, EditorialDecision.None, latest.Decision)) + + If statistics.Outcome <> SubmissionOutcome.Open Then + statistics.ClosedDate = latest.DecisionDate.Date + statistics.DaysAtJournal = DaysBetween(submitted, latest.DecisionDate) + Else + statistics.DaysAtJournal = DaysBetween(submitted, today) + End If + + Return statistics + + End Function + + + Public Shared Function DescribeManuscript(manuscript As Manuscript, today As DateTime) As ManuscriptStatistics + + If manuscript Is Nothing Then Throw New ArgumentNullException(NameOf(manuscript)) + + Dim statistics As New ManuscriptStatistics With {.Manuscript = manuscript} + + statistics.Submissions = If(manuscript.Submissions, New List(Of JournalSubmission)()). + Where(Function(item) item IsNot Nothing). + OrderBy(Function(item) item.SubmittedDate). + Select(Function(item) DescribeSubmission(item, today)).ToList() + + statistics.JournalCount = statistics.Submissions. + Select(Function(item) NameKey(item.JournalName)).Distinct().Count() + + If statistics.Submissions.Count > 0 Then + statistics.FirstSubmitted = statistics.Submissions(0).SubmittedDate + End If + + Dim accepted As SubmissionStatistics = statistics.Submissions.FirstOrDefault(Function(item) item.Outcome = SubmissionOutcome.Accepted) + If accepted IsNot Nothing Then statistics.AcceptedDate = accepted.ClosedDate + + statistics.PublishedDate = PublishedDateOf(manuscript) + + If statistics.FirstSubmitted.HasValue Then + If statistics.AcceptedDate.HasValue Then + statistics.DaysToAcceptance = DaysBetween(statistics.FirstSubmitted.Value, statistics.AcceptedDate.Value) + End If + If statistics.PublishedDate.HasValue Then + statistics.DaysToPublication = DaysBetween(statistics.FirstSubmitted.Value, statistics.PublishedDate.Value) + End If + End If + + Return statistics + + End Function + + + ' The publication date field, or else the date the manuscript was + ' recorded as published. Nothing when neither exists. + Public Shared Function PublishedDateOf(manuscript As Manuscript) As DateTime? + + If manuscript.Metadata IsNot Nothing AndAlso manuscript.Metadata.PublishedDate.HasValue Then + Return manuscript.Metadata.PublishedDate.Value.Date + End If + + Dim published As HistoryEvent = + If(manuscript.History, New List(Of HistoryEvent)()). + Where(Function(item) item IsNot Nothing AndAlso item.Stage = PaperStage.Published). + OrderBy(Function(item) item.EventDate).FirstOrDefault() + + Return If(published Is Nothing, CType(Nothing, DateTime?), published.EventDate.Date) + + End Function + + + Public Shared Function ForLibrary( + manuscripts As IEnumerable(Of Manuscript), + today As DateTime, + Optional journalLibrary As IEnumerable(Of JournalRecord) = Nothing + ) As LibraryStatistics + + If manuscripts Is Nothing Then Throw New ArgumentNullException(NameOf(manuscripts)) + + Dim library As New LibraryStatistics With { + .Manuscripts = manuscripts.Where(Function(item) item IsNot Nothing). + Select(Function(item) DescribeManuscript(item, today)).ToList() + } + + Dim submissions As List(Of SubmissionStatistics) = library.Submissions.ToList() + + library.MedianDaysToFirstDecision = MedianOf(submissions.Select(Function(item) item.DaysToFirstDecision)) + library.MedianDaysToDeskRejection = MedianOf(submissions.Where(Function(item) item.FirstDecision.HasValue AndAlso item.FirstDecision.Value = EditorialDecision.DeskRejected).Select(Function(item) item.DaysToFirstDecision)) + library.MedianReviewDays = MedianOf(submissions.Where(Function(item) item.WentToReview.GetValueOrDefault()).Select(Function(item) item.DaysToFirstDecision)) + library.MedianDaysToAcceptance = MedianOf(library.Manuscripts.Select(Function(item) item.DaysToAcceptance)) + library.MedianDaysToPublication = MedianOf(library.Manuscripts.Select(Function(item) item.DaysToPublication)) + + library.Journals = GroupByJournal(library.Manuscripts, journalLibrary) + + Return library + + End Function + + + ' Submissions linked to a Journal Library record count under that + ' record; free-text names count under the same record only when the + ' name matches it exactly (ignoring case and spacing). Anything else + ' keeps its own name: nothing is merged by guess. + Private Shared Function GroupByJournal(manuscripts As List(Of ManuscriptStatistics), journalLibrary As IEnumerable(Of JournalRecord)) As List(Of JournalHistory) + + Dim records As List(Of JournalRecord) = If(journalLibrary, Enumerable.Empty(Of JournalRecord)()).Where(Function(item) item IsNot Nothing).ToList() + Dim groups As New Dictionary(Of String, JournalHistory)(StringComparer.Ordinal) + + For Each manuscript As ManuscriptStatistics In manuscripts + For Each submission As SubmissionStatistics In manuscript.Submissions + + Dim record As JournalRecord = Nothing + If submission.Submission.JournalId.HasValue Then + record = records.FirstOrDefault(Function(item) item.Id = submission.Submission.JournalId.Value) + End If + If record Is Nothing AndAlso submission.JournalName.Length > 0 Then + Dim key As String = NameKey(submission.JournalName) + record = records.FirstOrDefault(Function(item) NameKey(item.Name) = key) + End If + + Dim groupKey As String + Dim display As String + If record IsNot Nothing Then + groupKey = "id:" & record.Id.ToString() + display = record.Name.Trim() + ElseIf submission.JournalName.Length > 0 Then + groupKey = "name:" & NameKey(submission.JournalName) + display = submission.JournalName + Else + groupKey = "none" + display = "(No journal recorded)" + End If + + Dim history As JournalHistory = Nothing + If Not groups.TryGetValue(groupKey, history) Then + history = New JournalHistory With {.JournalName = display, .JournalId = If(record Is Nothing, CType(Nothing, Guid?), record.Id)} + groups(groupKey) = history + End If + history.Submissions.Add((manuscript.Manuscript, submission)) + + Next + Next + + For Each history As JournalHistory In groups.Values + Dim items As List(Of SubmissionStatistics) = history.Submissions.Select(Function(item) item.Statistics).ToList() + history.MedianDaysToFirstDecision = MedianOf(items.Select(Function(item) item.DaysToFirstDecision)) + history.MedianReviewDays = MedianOf(items.Where(Function(item) item.WentToReview.GetValueOrDefault()).Select(Function(item) item.DaysToFirstDecision)) + Next + + Return groups.Values. + OrderByDescending(Function(item) item.Count). + ThenBy(Function(item) item.JournalName, StringComparer.CurrentCultureIgnoreCase).ToList() + + End Function + + + Public Shared Function MedianOf(values As IEnumerable(Of Integer?)) As Median + + Dim known As List(Of Integer) = values.Where(Function(item) item.HasValue).Select(Function(item) item.Value).OrderBy(Function(item) item).ToList() + If known.Count = 0 Then Return New Median(Nothing, 0) + + Dim middle As Integer = known.Count \ 2 + Dim value As Double = If(known.Count Mod 2 = 1, known(middle), (known(middle - 1) + known(middle)) / 2.0) + Return New Median(value, known.Count) + + End Function + + + Public Shared Function OutcomeOf(decision As EditorialDecision) As SubmissionOutcome + Select Case decision + Case EditorialDecision.DeskRejected : Return SubmissionOutcome.DeskRejected + Case EditorialDecision.RejectedAfterReview : Return SubmissionOutcome.RejectedAfterReview + Case EditorialDecision.Rejected : Return SubmissionOutcome.Rejected + Case EditorialDecision.Accepted : Return SubmissionOutcome.Accepted + Case EditorialDecision.Withdrawn : Return SubmissionOutcome.Withdrawn + Case Else : Return SubmissionOutcome.Open + End Select + End Function + + + Private Shared Function IsRevision(decision As EditorialDecision) As Boolean + Return decision = EditorialDecision.MajorRevision OrElse + decision = EditorialDecision.MinorRevision OrElse + decision = EditorialDecision.ReviseAndResubmit + End Function + + + ' A later date before an earlier one is a data-entry slip, not a + ' negative duration; it is left out. + Private Shared Function DaysBetween(start As DateTime, finish As DateTime) As Integer? + Dim days As Integer = (finish.Date - start.Date).Days + Return If(days >= 0, days, CType(Nothing, Integer?)) + End Function + + + Friend Shared Function NameKey(name As String) As String + Return Regex.Replace(If(name, String.Empty).Trim(), "\s+", " ").ToUpperInvariant() + End Function + + End Class + +End Namespace diff --git a/PaperRoute.Tests/DashboardShelfLayoutTests.vb b/PaperRoute.Tests/DashboardShelfLayoutTests.vb index 3c851ff..e7ebbe6 100644 --- a/PaperRoute.Tests/DashboardShelfLayoutTests.vb +++ b/PaperRoute.Tests/DashboardShelfLayoutTests.vb @@ -341,7 +341,7 @@ Public Class DashboardShelfLayoutTests Application.DoEvents() CollectionAssert.AreEqual( - {"Board", "Library", "Journals", "Deadlines", "Import & Export"}, + {"Board", "Library", "Journals", "Deadlines", "Insights", "Import & Export"}, board.RailPages.Select(Function(page) page.Text).ToList()) Assert.AreEqual("Board", board.ActivePageName) Assert.IsFalse(Descendants(board).OfType(Of Button)().Any(Function(button) button.Text.StartsWith("Data")), @@ -355,7 +355,7 @@ Public Class DashboardShelfLayoutTests Dim grid As DataGridView = Descendants(board).OfType(Of DataGridView)().Single() Assert.AreEqual(90, grid.Rows.Count, "The Library lists every manuscript on every shelf.") - board.PressCommandKey(Keys.Control Or Keys.D5) + board.PressCommandKey(Keys.Control Or Keys.D6) Application.DoEvents() Assert.AreEqual("ImportExport", board.ActivePageName) @@ -384,7 +384,7 @@ Public Class DashboardShelfLayoutTests board.Show() Application.DoEvents() - board.PressCommandKey(Keys.Control Or Keys.D5) + board.PressCommandKey(Keys.Control Or Keys.D6) Application.DoEvents() Dim commands As List(Of String) = Descendants(board).OfType(Of Button)().Select(Function(button) button.Text).ToList() For Each command As String In { diff --git a/PaperRoute.Tests/ManuscriptPageTests.vb b/PaperRoute.Tests/ManuscriptPageTests.vb index 53b8176..288125a 100644 --- a/PaperRoute.Tests/ManuscriptPageTests.vb +++ b/PaperRoute.Tests/ManuscriptPageTests.vb @@ -109,7 +109,7 @@ Public Class ManuscriptPageTests board.Open(manuscript) board.Field("Title").Text = "Kept title" board.SetPrompt(Function(title) DialogResult.Yes) - board.PressCommandKey(Keys.Control Or Keys.D5) + board.PressCommandKey(Keys.Control Or Keys.D6) Assert.AreEqual("ImportExport", board.PageName) Assert.AreEqual("Kept title", manuscript.Title, "Yes saves before leaving.") Assert.AreEqual(1, board.SaveCount) @@ -652,6 +652,73 @@ Public Class ManuscriptPageTests End Function End Class + ' Insights (#30, #62): your journals and routes, from recorded dates. + + Public Sub InsightsSummarizeJournalsAndRoutesFromTheLibrary() + RunOnStaThread( + Sub() + Using board As New PageBoard() + Dim routed As Manuscript = WithRoute() + board.Prepare(routed, Sample("Unsubmitted idea")) + + board.PressCommandKey(Keys.Control Or Keys.D5) + Assert.AreEqual("Insights", board.PageName) + Dim tiles As List(Of String) = Descendants(board).OfType(Of RoundedPanel)().Select(Function(tile) tile.AccessibleName).Where(Function(name) name IsNot Nothing).ToList() + Assert.IsTrue(tiles.Contains("2 submissions · 0 accepted"), String.Join(" | ", tiles)) + Assert.IsTrue(tiles.Contains("19.5 days median to a first decision (of 2)"), "The median of 7 and 32 days.") + Assert.IsTrue(tiles.Contains("— median from first submission to acceptance (not recorded yet)"), "Nothing is estimated.") + + Dim journals As DataGridView = Descendants(board).OfType(Of DataGridView)().Single() + CollectionAssert.AreEquivalent({"First Journal", "Second Journal"}, journals.Rows.Cast(Of DataGridViewRow)().Select(Function(row) CStr(row.Cells("Journal").Value)).ToList()) + Dim second As DataGridViewRow = journals.Rows.Cast(Of DataGridViewRow)().Single(Function(row) CStr(row.Cells("Journal").Value) = "Second Journal") + Assert.AreEqual(1.0, CDbl(second.Cells("Revisions").Value)) + Assert.AreEqual(32.0, CDbl(second.Cells("FirstDecision").Value)) + + journals.CurrentCell = second.Cells(0) + GetType(Control).GetMethod("OnKeyDown", BindingFlags.Instance Or BindingFlags.NonPublic).Invoke(journals, New Object() {New KeyEventArgs(Keys.Enter)}) + Application.DoEvents() + Dim routes As DataGridView = Descendants(board).OfType(Of DataGridView)().Single() + Assert.AreEqual(1, routes.Rows.Count, "Enter on a journal shows the routes that went through it.") + Assert.AreEqual("Routed manuscript", CStr(routes.Rows(0).Cells("Title").Value)) + Assert.IsTrue(Descendants(board).OfType(Of LinkLabel)().Any(Function(link) link.Visible AndAlso link.Text.StartsWith("Showing manuscripts sent to Second Journal"))) + + routes.CurrentCell = routes.Rows(0).Cells(0) + GetType(Control).GetMethod("OnKeyDown", BindingFlags.Instance Or BindingFlags.NonPublic).Invoke(routes, New Object() {New KeyEventArgs(Keys.Enter)}) + Application.DoEvents() + Assert.AreEqual("Manuscript", board.PageName, "Enter on a route opens the manuscript.") + Assert.AreEqual(0, board.SaveCount, "Insights never save.") + + board.Close() + End Using + End Sub) + End Sub + + + Public Sub ReportsPreviewAndSaveWhatIsChosen() + RunOnStaThread( + Sub() + Dim path As String = IO.Path.Combine(IO.Path.GetTempPath(), "PaperRoute-Report-" & Guid.NewGuid().ToString("N") & ".html") + Try + Dim pipeline As Manuscript = Sample("In the pipeline") + Dim drawer As Manuscript = Sample("In the drawer") + drawer.Location = ManuscriptLocation.FileDrawer + Using dialog As New ReportForm("Pipeline Report", "report.html", New List(Of Manuscript) From {pipeline, drawer}, + Function(chosen, deadlines) ReportService.PipelineReport(chosen, DateTime.Today)) + dialog.SavePathPrompt = Function() path + ShowOffscreen(dialog) + CollectionAssert.AreEqual({pipeline}, dialog.Chosen, "Pipeline work is included by default; the File Drawer is not.") + dialog.SaveReport(Nothing, EventArgs.Empty) + Assert.AreEqual(path, dialog.SavedPath) + End Using + Dim saved As String = IO.File.ReadAllText(path) + StringAssert.Contains(saved, "In the pipeline") + Assert.IsFalse(saved.Contains("In the drawer")) + Finally + If IO.File.Exists(path) Then IO.File.Delete(path) + End Try + End Sub) + End Sub + Private Shared Function DeadlineRows(board As PageBoard) As List(Of DeadlineRow) Return Descendants(board).OfType(Of DeadlineRow)().ToList() End Function diff --git a/PaperRoute.Tests/ReportServiceTests.vb b/PaperRoute.Tests/ReportServiceTests.vb new file mode 100644 index 0000000..96bc637 --- /dev/null +++ b/PaperRoute.Tests/ReportServiceTests.vb @@ -0,0 +1,83 @@ +Imports System +Imports System.Collections.Generic +Imports System.Linq +Imports System.Text.Json +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports ManuscriptPipeline.Models +Imports ManuscriptPipeline.Services + +' Shareable reports (#30, #63): only what a supervisor or coauthor should +' see, escaped, and built without changing a record. + +Public Class ReportServiceTests + + Private Shared ReadOnly Today As New DateTime(2026, 9, 28) + + + Public Sub PipelineReportLeavesOutPrivateMaterial() + Dim manuscript As Manuscript = Sensitive() + Dim before As String = JsonSerializer.Serialize(manuscript) + + Dim html As String = ReportService.PipelineReport({manuscript}, Today, "Dr. Example") + + StringAssert.Contains(html, "Grit & resilience <script>", "Titles are escaped.") + Assert.IsFalse(html.Contains("