Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/Envoy.Core/Configuration/EnvoySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public class EnvoySettings
/// </summary>
public bool CheckForUpdates { get; set; } = true;
public string TemplatesPath { get; set; } = "";

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
29 changes: 29 additions & 0 deletions src/Envoy.Core/Models/ScoreboardStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Envoy.Core.Models;

/// <summary>
/// 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.
/// </summary>
public record ScoreboardStats(
int GhostsDodged,
double HoursSaved,
int MinutesPerApplication,
int PostingsScreened,
int GhostsSurfaced,
int Applications,
int StreakDays,
IReadOnlyList<DodgeReceipt> RecentDodges);

/// <summary>
/// One dodged posting with the evidence that was on screen when the user
/// passed on it: the receipt behind the headline number.
/// </summary>
public record DodgeReceipt(
DateTime OccurredAtUtc,
string Company,
string JobTitle,
string RiskBand,
double? RiskScore,
string? Evidence,
string JobUrl);
71 changes: 71 additions & 0 deletions src/Envoy.Core/Services/ScoreboardCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Envoy.Core.Models;

namespace Envoy.Core.Services;

/// <summary>
/// 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.
/// </summary>
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<JobEvent> 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<JobEvent> 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;
}
}
1 change: 1 addition & 0 deletions src/Envoy.UI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ private void App_OnStartup(object sender, StartupEventArgs e)
services.AddEnvoyDiscovery();
services.AddScoped<IResumePdfGenerator, Envoy.Assets.Pdf.ResumePdfGenerator>();
services.AddSingleton<MainWindow>();
services.AddSingleton<ScoreboardView>();
services.AddSingleton<DashboardView>();
services.AddSingleton<ApplyView>();
services.AddSingleton<FindJobsView>();
Expand Down
3 changes: 2 additions & 1 deletion src/Envoy.UI/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
</Grid.ColumnDefinitions>

<StackPanel Grid.Column="0" Background="#0D1117" Margin="0,0,0,0">
<Button x:Name="NavDashboard" Content="&#x25C8; DASHBOARD" Click="NavDashboard_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,12,8,2" HorizontalContentAlignment="Left"/>
<Button x:Name="NavScoreboard" Content="&#x25C8; SCOREBOARD" Click="NavScoreboard_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,12,8,2" HorizontalContentAlignment="Left"/>
<Button x:Name="NavDashboard" Content="&#x25C8; DASHBOARD" Click="NavDashboard_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,2,8,2" HorizontalContentAlignment="Left"/>
<Button x:Name="NavFind" Content="&#x2316; FIND JOBS" Click="NavFind_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,2,8,2" HorizontalContentAlignment="Left"/>
<Button x:Name="NavApply" Content="&#x25C8; APPLY" Click="NavApply_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,2,8,2" HorizontalContentAlignment="Left"/>
<Button x:Name="NavVault" Content="&#x25C8; VAULT" Click="NavVault_Click" Style="{StaticResource CyberNavButton}" Height="44" Margin="8,2,8,2" HorizontalContentAlignment="Left"/>
Expand Down
18 changes: 15 additions & 3 deletions src/Envoy.UI/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Loading