From 22584a22e8eb207d954007780440c9fda05cb15f Mon Sep 17 00:00:00 2001 From: LXBStudioLLC Date: Sat, 18 Jul 2026 15:26:26 -0400 Subject: [PATCH] feat(ui): the Scoreboard, wired as the startup view Four tiles: ghosts dodged, hours saved, day streak, ghosts surfaced. Dodges are explicit acts only (a skip or gate decline on a flagged posting); a flagged posting merely appearing in results never counts. Hours saved shows its math on the board: dodges times the user's own time-per-application estimate, adjustable in place and stored in settings. Screened and sent counts appear as one muted context line. A receipts list backs the headline number: every dodge with the band, score, and evidence that was on screen when the user walked, newest first, with a VIEW link. ScoreboardCalculator is a pure function over ledger events with its own tests (dodge rules, formula fallback, local-day streak semantics, receipt ordering and cap). The view is the seventh sidebar entry and replaces Dashboard as what opens first. --- AGENTS.md | 2 +- CHANGELOG.md | 1 + src/Envoy.Core/Configuration/EnvoySettings.cs | 7 + src/Envoy.Core/Models/ScoreboardStats.cs | 29 ++++ .../Services/ScoreboardCalculator.cs | 71 +++++++++ src/Envoy.UI/App.xaml.cs | 1 + src/Envoy.UI/MainWindow.xaml | 3 +- src/Envoy.UI/MainWindow.xaml.cs | 18 ++- src/Envoy.UI/ScoreboardView.xaml | 109 +++++++++++++ src/Envoy.UI/ScoreboardView.xaml.cs | 149 ++++++++++++++++++ .../ScoreboardCalculatorTests.cs | 129 +++++++++++++++ 11 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 src/Envoy.Core/Models/ScoreboardStats.cs create mode 100644 src/Envoy.Core/Services/ScoreboardCalculator.cs create mode 100644 src/Envoy.UI/ScoreboardView.xaml create mode 100644 src/Envoy.UI/ScoreboardView.xaml.cs create mode 100644 tests/Envoy.Core.Tests/ScoreboardCalculatorTests.cs 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 @@ -