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 @@ -