diff --git a/AGENTS.md b/AGENTS.md
index 4f988e5..4879b40 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,7 +67,7 @@ src/
Sources/ — Greenhouse, Lever, Ashby, Workable, Recruitee, Brave
JobDiscoveryService.cs — aggregates public postings, ghost-scores them
ServiceRegistration.cs — AddEnvoyDiscovery()
- Envoy.UI/ WPF views (incl. Find Jobs + Apply ghost-risk panel), app host
+ Envoy.UI/ WPF views (Scoreboard start view, Find Jobs + Apply ghost-risk panel), app host
Envoy.Assets/ PDF generation, fonts
Envoy.Templates/ JSON templates for supported job boards
tests/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4804b89..af5342b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to Envoy are documented in this file. Format is based on [Ke
## [Unreleased]
### Added
+- The Scoreboard. A new view, and the first thing you see when Envoy opens: ghosts dodged, hours saved, day streak, and ghosts surfaced, with a receipts list showing every dodge alongside the evidence that was on screen when you passed. Hours saved shows its math (dodges times your own time-per-application estimate, adjustable right on the board) instead of pretending to measure something it can't. Screened and sent counts appear only as context; the board scores the system, it does not grade you.
- Find Jobs feeds the ledger. Every scored result is recorded as a sighting (one per posting per day, so re-running a search inflates nothing), opening a posting records a view, and a new SKIP button on each result records the pass and clears the row. Skipping a flagged posting answers with GHOST DODGED in the status line. Day-by-day sightings are also the listing history the repost-frequency signal needs for its stronger path.
- Scoreboard foundation. Envoy now keeps a local activity ledger (a `JobEvents` table in `envoy.db`): a submitted application is recorded as Applied, and a cancel at the submit gate as Declined, each carrying the ghost risk score, band, and evidence that was on screen when the user decided. Application logs also store the score and band at submit time. Cancelling at the gate gets its own status (`DeclinedByUser`) instead of being lumped in with the safety-check halt, and the Apply view shows it in yellow, not error red, because walking away from a bad posting is a decision, not a failure. This ledger is the data layer for the upcoming stats view, and it begins the cross-session listing history the repost-frequency signal has been waiting on.
- In-app update check. Once per launch, Envoy asks the public GitHub releases API whether a newer version exists and, when one does, shows an UPDATE link in the title bar that opens the release page. Nothing is sent beyond the request itself; set `"CheckForUpdates": false` in `settings.json` to turn it off.
diff --git a/src/Envoy.Core/Configuration/EnvoySettings.cs b/src/Envoy.Core/Configuration/EnvoySettings.cs
index fe12605..9b7ad82 100644
--- a/src/Envoy.Core/Configuration/EnvoySettings.cs
+++ b/src/Envoy.Core/Configuration/EnvoySettings.cs
@@ -35,6 +35,13 @@ public class EnvoySettings
///
public bool CheckForUpdates { get; set; } = true;
public string TemplatesPath { get; set; } = "";
+
+ ///
+ /// The user's own estimate of how long one application takes them by hand,
+ /// in minutes. Multiplied by ghosts dodged for the hours-saved stat; the
+ /// scoreboard shows the formula rather than pretending to measure it.
+ ///
+ public int MinutesPerApplicationEstimate { get; set; } = 45;
public int TypingSpeedVariance { get; set; } = 35;
public int MousePathSteps { get; set; } = 25;
public double RelocationConfidenceThreshold { get; set; } = 0.75;
diff --git a/src/Envoy.Core/Models/ScoreboardStats.cs b/src/Envoy.Core/Models/ScoreboardStats.cs
new file mode 100644
index 0000000..44c21ae
--- /dev/null
+++ b/src/Envoy.Core/Models/ScoreboardStats.cs
@@ -0,0 +1,29 @@
+namespace Envoy.Core.Models;
+
+///
+/// The numbers behind the scoreboard, computed from the ledger. Every stat is
+/// a point scored against the system, never a judgment of the player: dodges
+/// and hours saved are the headline, submit counts appear only as context.
+///
+public record ScoreboardStats(
+ int GhostsDodged,
+ double HoursSaved,
+ int MinutesPerApplication,
+ int PostingsScreened,
+ int GhostsSurfaced,
+ int Applications,
+ int StreakDays,
+ IReadOnlyList RecentDodges);
+
+///
+/// One dodged posting with the evidence that was on screen when the user
+/// passed on it: the receipt behind the headline number.
+///
+public record DodgeReceipt(
+ DateTime OccurredAtUtc,
+ string Company,
+ string JobTitle,
+ string RiskBand,
+ double? RiskScore,
+ string? Evidence,
+ string JobUrl);
diff --git a/src/Envoy.Core/Services/ScoreboardCalculator.cs b/src/Envoy.Core/Services/ScoreboardCalculator.cs
new file mode 100644
index 0000000..3f097fd
--- /dev/null
+++ b/src/Envoy.Core/Services/ScoreboardCalculator.cs
@@ -0,0 +1,71 @@
+using Envoy.Core.Models;
+
+namespace Envoy.Core.Services;
+
+///
+/// Pure computation from ledger events to scoreboard stats. A dodge is an
+/// explicit act: a skip or a gate decline on a posting that was flagged
+/// Elevated or High when the user saw it. A flagged posting merely appearing
+/// in results never counts — inflated numbers read as fake, and the board's
+/// credibility is the product.
+///
+public static class ScoreboardCalculator
+{
+ private const string BandElevated = "Elevated";
+ private const string BandHigh = "High";
+ private const int DefaultMinutesPerApplication = 45;
+ private const int MaxReceipts = 10;
+
+ public static ScoreboardStats Compute(IReadOnlyList events, int minutesPerApplication, DateTime nowLocal)
+ {
+ if (minutesPerApplication <= 0)
+ minutesPerApplication = DefaultMinutesPerApplication;
+
+ var dodges = events
+ .Where(e => (e.Type == JobEventType.Skipped || e.Type == JobEventType.Declined) && IsFlagged(e))
+ .OrderByDescending(e => e.OccurredAt)
+ .ToList();
+
+ var receipts = dodges
+ .Take(MaxReceipts)
+ .Select(e => new DodgeReceipt(
+ e.OccurredAt, e.Company, e.JobTitle, e.RiskBand ?? "", e.RiskScore, e.Evidence, e.JobUrl))
+ .ToList();
+
+ return new ScoreboardStats(
+ GhostsDodged: dodges.Count,
+ HoursSaved: Math.Round(dodges.Count * minutesPerApplication / 60.0, 1),
+ MinutesPerApplication: minutesPerApplication,
+ PostingsScreened: events.Count(e => e.Type == JobEventType.Sighted),
+ GhostsSurfaced: events.Count(e => e.Type == JobEventType.Sighted && IsFlagged(e)),
+ Applications: events.Count(e => e.Type == JobEventType.Applied),
+ StreakDays: ComputeStreak(events, nowLocal),
+ RecentDodges: receipts);
+ }
+
+ private static bool IsFlagged(JobEvent e) => e.RiskBand is BandElevated or BandHigh;
+
+ // Consecutive local calendar days with any ledger activity, counting back
+ // from today. A streak whose last active day was yesterday still counts —
+ // today isn't over, so it hasn't been broken yet.
+ private static int ComputeStreak(IReadOnlyList events, DateTime nowLocal)
+ {
+ if (events.Count == 0) return 0;
+ var activeDays = events.Select(e => e.OccurredAt.ToLocalTime().Date).ToHashSet();
+
+ var day = nowLocal.Date;
+ if (!activeDays.Contains(day))
+ {
+ day = day.AddDays(-1);
+ if (!activeDays.Contains(day)) return 0;
+ }
+
+ var streak = 0;
+ while (activeDays.Contains(day))
+ {
+ streak++;
+ day = day.AddDays(-1);
+ }
+ return streak;
+ }
+}
diff --git a/src/Envoy.UI/App.xaml.cs b/src/Envoy.UI/App.xaml.cs
index 6862d6a..d89a842 100644
--- a/src/Envoy.UI/App.xaml.cs
+++ b/src/Envoy.UI/App.xaml.cs
@@ -60,6 +60,7 @@ private void App_OnStartup(object sender, StartupEventArgs e)
services.AddEnvoyDiscovery();
services.AddScoped();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Envoy.UI/MainWindow.xaml b/src/Envoy.UI/MainWindow.xaml
index 67d79b3..fec49a8 100644
--- a/src/Envoy.UI/MainWindow.xaml
+++ b/src/Envoy.UI/MainWindow.xaml
@@ -50,7 +50,8 @@
-
+
+
diff --git a/src/Envoy.UI/MainWindow.xaml.cs b/src/Envoy.UI/MainWindow.xaml.cs
index e1260d3..d5b7204 100644
--- a/src/Envoy.UI/MainWindow.xaml.cs
+++ b/src/Envoy.UI/MainWindow.xaml.cs
@@ -14,6 +14,7 @@ namespace Envoy.UI;
public partial class MainWindow : Window
{
+ private readonly ScoreboardView _scoreboard;
private readonly DashboardView _dashboard;
private readonly FindJobsView _find;
private readonly ApplyView _apply;
@@ -30,8 +31,9 @@ public partial class MainWindow : Window
private readonly TranslateTransform _titleTransform = new(0, 0);
private Random _rng = new();
- public MainWindow(DashboardView dashboard, FindJobsView find, ApplyView apply, VaultView vault, BrowserSelectionView browser, LLMSettingsView llmSettings, IBrowserLauncher browserLauncher, HardwareProfiler hardwareProfiler, IUpdateCheckService updateCheck, EnvoySettings settings)
+ public MainWindow(ScoreboardView scoreboard, DashboardView dashboard, FindJobsView find, ApplyView apply, VaultView vault, BrowserSelectionView browser, LLMSettingsView llmSettings, IBrowserLauncher browserLauncher, HardwareProfiler hardwareProfiler, IUpdateCheckService updateCheck, EnvoySettings settings)
{
+ _scoreboard = scoreboard;
_dashboard = dashboard;
_find = find;
_apply = apply;
@@ -49,8 +51,10 @@ public MainWindow(DashboardView dashboard, FindJobsView find, ApplyView apply, V
Closed += MainWindow_Closed;
- NavigateTo(_dashboard);
- UpdateNavButtons("Dashboard");
+ // The scoreboard is the front door: opening Envoy should feel like
+ // checking the score, not starting a chore.
+ NavigateTo(_scoreboard);
+ UpdateNavButtons("Scoreboard");
StartGlitchEffect();
}
@@ -279,6 +283,8 @@ public void NavigateTo(UserControl view)
private void UpdateNavButtons(string active)
{
+ NavScoreboard.Background = active == "Scoreboard" ? NavActiveBg : Transparent;
+ NavScoreboard.Foreground = active == "Scoreboard" ? Cyan : Gray;
NavDashboard.Background = active == "Dashboard" ? NavActiveBg : Transparent;
NavDashboard.Foreground = active == "Dashboard" ? Cyan : Gray;
NavFind.Background = active == "Find" ? NavActiveBg : Transparent;
@@ -293,6 +299,12 @@ private void UpdateNavButtons(string active)
NavLLM.Foreground = active == "LLM" ? Cyan : Gray;
}
+ private void NavScoreboard_Click(object sender, RoutedEventArgs e)
+ {
+ NavigateTo(_scoreboard);
+ UpdateNavButtons("Scoreboard");
+ }
+
private void NavDashboard_Click(object sender, RoutedEventArgs e)
{
NavigateTo(_dashboard);
diff --git a/src/Envoy.UI/ScoreboardView.xaml b/src/Envoy.UI/ScoreboardView.xaml
new file mode 100644
index 0000000..a547538
--- /dev/null
+++ b/src/Envoy.UI/ScoreboardView.xaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Envoy.UI/ScoreboardView.xaml.cs b/src/Envoy.UI/ScoreboardView.xaml.cs
new file mode 100644
index 0000000..1373bf2
--- /dev/null
+++ b/src/Envoy.UI/ScoreboardView.xaml.cs
@@ -0,0 +1,149 @@
+using Envoy.Core.Configuration;
+using Envoy.Core.Models;
+using Envoy.Core.Services;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using static Envoy.UI.Theme;
+
+namespace Envoy.UI;
+
+public partial class ScoreboardView : UserControl
+{
+ private readonly IJobEventRepository _jobEvents;
+ private readonly EnvoySettings _settings;
+ private bool _suppressMinutesChanged;
+
+ public ScoreboardView(IJobEventRepository jobEvents, EnvoySettings settings)
+ {
+ _jobEvents = jobEvents;
+ _settings = settings;
+ InitializeComponent();
+ SelectMinutesItem(_settings.MinutesPerApplicationEstimate);
+ Loaded += async (_, _) => await RefreshAsync();
+ }
+
+ public async Task RefreshAsync()
+ {
+ try
+ {
+ var events = await _jobEvents.GetAllAsync();
+ var stats = ScoreboardCalculator.Compute(events, _settings.MinutesPerApplicationEstimate, DateTime.Now);
+ Render(stats, events.Count == 0);
+ }
+ catch (Exception ex)
+ {
+ StatusLine.Text = $"✕ Could not read the ledger: {ex.Message}";
+ StatusLine.Foreground = Red;
+ }
+ }
+
+ private void Render(ScoreboardStats stats, bool ledgerEmpty)
+ {
+ DodgedValue.Text = stats.GhostsDodged.ToString();
+ HoursValue.Text = stats.HoursSaved.ToString("0.0");
+ HoursFormula.Text = $"{stats.GhostsDodged} DODGE{(stats.GhostsDodged == 1 ? "" : "S")} × {stats.MinutesPerApplication} MIN";
+ StreakValue.Text = stats.StreakDays.ToString();
+ SurfacedValue.Text = stats.GhostsSurfaced.ToString();
+
+ ContextLine.Text = $"POSTINGS SCREENED: {stats.PostingsScreened} · APPLICATIONS SENT: {stats.Applications}";
+ ColdStartLabel.Visibility = ledgerEmpty ? Visibility.Visible : Visibility.Collapsed;
+
+ var receipts = stats.RecentDodges.Select(ToReceiptItem).ToList();
+ ReceiptsList.ItemsSource = receipts;
+ NoReceiptsLabel.Visibility = receipts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private static ReceiptItem ToReceiptItem(DodgeReceipt receipt)
+ {
+ var (brush, label) = receipt.RiskBand switch
+ {
+ "High" => ((Brush)Red, "HIGH"),
+ "Elevated" => (Yellow, "ELEVATED"),
+ _ => (Gray, receipt.RiskBand.ToUpperInvariant())
+ };
+
+ return new ReceiptItem
+ {
+ Company = string.IsNullOrWhiteSpace(receipt.Company) ? "—" : receipt.Company,
+ Title = string.IsNullOrWhiteSpace(receipt.JobTitle) ? "—" : receipt.JobTitle,
+ DateText = $"DODGED {receipt.OccurredAtUtc.ToLocalTime():yyyy-MM-dd HH:mm}",
+ BadgeText = receipt.RiskScore is { } score ? $"{label} {score:0}" : label,
+ BadgeBrush = brush,
+ Evidence = string.IsNullOrWhiteSpace(receipt.Evidence)
+ ? ""
+ : string.Join("\n", receipt.Evidence.Split('\n').Select(line => "• " + line)),
+ EvidenceVisibility = string.IsNullOrWhiteSpace(receipt.Evidence) ? Visibility.Collapsed : Visibility.Visible,
+ Url = receipt.JobUrl
+ };
+ }
+
+ private void SelectMinutesItem(int minutes)
+ {
+ _suppressMinutesChanged = true;
+ try
+ {
+ foreach (var obj in CmbMinutes.Items)
+ {
+ if (obj is ComboBoxItem item && item.Tag is string tag
+ && int.TryParse(tag, out var value) && value == minutes)
+ {
+ CmbMinutes.SelectedItem = item;
+ return;
+ }
+ }
+ // A hand-edited settings value that isn't a preset: leave the box
+ // unselected; the formula line still shows the value in effect.
+ }
+ finally
+ {
+ _suppressMinutesChanged = false;
+ }
+ }
+
+ private async void CmbMinutes_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressMinutesChanged) return;
+ if (CmbMinutes.SelectedItem is not ComboBoxItem item || item.Tag is not string tag
+ || !int.TryParse(tag, out var minutes))
+ return;
+
+ _settings.MinutesPerApplicationEstimate = minutes;
+ if (!_settings.Save())
+ {
+ StatusLine.Text = "✕ Could not save settings — settings.json may be locked. The estimate applies for this session only.";
+ StatusLine.Foreground = Yellow;
+ }
+ else
+ {
+ StatusLine.Text = "";
+ }
+ await RefreshAsync();
+ }
+
+ private void BtnViewReceipt_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is string url && !string.IsNullOrWhiteSpace(url))
+ {
+ try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
+ catch (Exception ex)
+ {
+ StatusLine.Text = $"✕ Could not open link: {ex.Message}";
+ StatusLine.Foreground = Red;
+ }
+ }
+ }
+}
+
+public class ReceiptItem
+{
+ public string Company { get; init; } = "";
+ public string Title { get; init; } = "";
+ public string DateText { get; init; } = "";
+ public string BadgeText { get; init; } = "";
+ public Brush BadgeBrush { get; init; } = Brushes.Gray;
+ public string Evidence { get; init; } = "";
+ public Visibility EvidenceVisibility { get; init; } = Visibility.Collapsed;
+ public string Url { get; init; } = "";
+}
diff --git a/tests/Envoy.Core.Tests/ScoreboardCalculatorTests.cs b/tests/Envoy.Core.Tests/ScoreboardCalculatorTests.cs
new file mode 100644
index 0000000..2eb0c86
--- /dev/null
+++ b/tests/Envoy.Core.Tests/ScoreboardCalculatorTests.cs
@@ -0,0 +1,129 @@
+using Envoy.Core.Models;
+using Envoy.Core.Services;
+using Xunit;
+
+namespace Envoy.Core.Tests;
+
+public class ScoreboardCalculatorTests
+{
+ // Fixed local noon in mid-July: far from midnight and from DST transitions,
+ // so local/UTC round trips are stable on any machine.
+ private static readonly DateTime NowLocal = new(2026, 7, 15, 12, 0, 0, DateTimeKind.Local);
+
+ private static DateTime UtcDaysAgo(int daysAgo) => NowLocal.AddDays(-daysAgo).ToUniversalTime();
+
+ private static JobEvent Ev(JobEventType type, string? band = null, int daysAgo = 0, double? score = null) => new()
+ {
+ Type = type,
+ RiskBand = band,
+ RiskScore = score,
+ OccurredAt = UtcDaysAgo(daysAgo),
+ Company = "Acme",
+ JobTitle = "Engineer",
+ JobUrl = "https://boards.greenhouse.io/acme/jobs/123",
+ PostingKey = "boards.greenhouse.io/acme/jobs/123",
+ Evidence = "Closed on the company ATS"
+ };
+
+ private static ScoreboardStats Compute(params JobEvent[] events) =>
+ ScoreboardCalculator.Compute(events, 45, NowLocal);
+
+ [Fact]
+ public void Dodges_AreExplicitActsOnFlaggedPostingsOnly()
+ {
+ var stats = Compute(
+ Ev(JobEventType.Skipped, "High"),
+ Ev(JobEventType.Declined, "Elevated"),
+ Ev(JobEventType.Skipped, "Neutral"), // skip of a clean posting: not a dodge
+ Ev(JobEventType.Skipped), // unscored skip: not a dodge
+ Ev(JobEventType.Sighted, "High"), // flagged but only seen: never a dodge
+ Ev(JobEventType.Applied, "High")); // applied anyway: not a dodge
+
+ Assert.Equal(2, stats.GhostsDodged);
+ }
+
+ [Fact]
+ public void HoursSaved_IsDodgesTimesMinutes_AndShowsItsInputs()
+ {
+ var events = new[]
+ {
+ Ev(JobEventType.Skipped, "High"),
+ Ev(JobEventType.Skipped, "High"),
+ Ev(JobEventType.Declined, "Elevated")
+ };
+
+ var stats = ScoreboardCalculator.Compute(events, 40, NowLocal);
+
+ Assert.Equal(2.0, stats.HoursSaved);
+ Assert.Equal(40, stats.MinutesPerApplication);
+ }
+
+ [Fact]
+ public void NonPositiveMinutes_FallBackToDefault()
+ {
+ var stats = ScoreboardCalculator.Compute(
+ new[] { Ev(JobEventType.Skipped, "High") }, 0, NowLocal);
+
+ Assert.Equal(45, stats.MinutesPerApplication);
+ Assert.Equal(0.8, stats.HoursSaved);
+ }
+
+ [Fact]
+ public void VillainStats_CountSightingsAndFlaggedSightings()
+ {
+ var stats = Compute(
+ Ev(JobEventType.Sighted, "Neutral"),
+ Ev(JobEventType.Sighted, "High"),
+ Ev(JobEventType.Sighted, "Elevated"),
+ Ev(JobEventType.Viewed, "High"), // a view is not a sighting
+ Ev(JobEventType.Applied));
+
+ Assert.Equal(3, stats.PostingsScreened);
+ Assert.Equal(2, stats.GhostsSurfaced);
+ Assert.Equal(1, stats.Applications);
+ }
+
+ [Fact]
+ public void Streak_CountsConsecutiveActiveDays_EndingTodayOrYesterday()
+ {
+ Assert.Equal(3, Compute(
+ Ev(JobEventType.Sighted, daysAgo: 0),
+ Ev(JobEventType.Sighted, daysAgo: 0), // same day counts once
+ Ev(JobEventType.Viewed, daysAgo: 1),
+ Ev(JobEventType.Applied, daysAgo: 2)).StreakDays);
+
+ // Nothing yet today: the run ending yesterday still stands.
+ Assert.Equal(2, Compute(
+ Ev(JobEventType.Sighted, daysAgo: 1),
+ Ev(JobEventType.Sighted, daysAgo: 2)).StreakDays);
+
+ // A gap before yesterday breaks it.
+ Assert.Equal(1, Compute(
+ Ev(JobEventType.Sighted, daysAgo: 0),
+ Ev(JobEventType.Sighted, daysAgo: 2)).StreakDays);
+
+ // Last activity two days ago: no live streak.
+ Assert.Equal(0, Compute(Ev(JobEventType.Sighted, daysAgo: 2)).StreakDays);
+
+ Assert.Equal(0, Compute().StreakDays);
+ }
+
+ [Fact]
+ public void Receipts_AreFlaggedDodgesNewestFirst_CappedAtTen()
+ {
+ var events = new List();
+ for (var i = 0; i < 12; i++)
+ events.Add(Ev(JobEventType.Skipped, "High", daysAgo: i, score: 80 + i));
+ events.Add(Ev(JobEventType.Skipped, "Neutral"));
+
+ var stats = ScoreboardCalculator.Compute(events, 45, NowLocal);
+
+ Assert.Equal(12, stats.GhostsDodged);
+ Assert.Equal(10, stats.RecentDodges.Count);
+ Assert.Equal(80, stats.RecentDodges[0].RiskScore); // newest (daysAgo 0) first
+ Assert.Equal("High", stats.RecentDodges[0].RiskBand);
+ Assert.Equal("Acme", stats.RecentDodges[0].Company);
+ Assert.Equal("Closed on the company ATS", stats.RecentDodges[0].Evidence);
+ Assert.True(stats.RecentDodges[0].OccurredAtUtc > stats.RecentDodges[9].OccurredAtUtc);
+ }
+}