diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03d1426..4804b89 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
+- 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/Models/JobEvent.cs b/src/Envoy.Core/Models/JobEvent.cs
index 6a3e221..68eaffd 100644
--- a/src/Envoy.Core/Models/JobEvent.cs
+++ b/src/Envoy.Core/Models/JobEvent.cs
@@ -36,6 +36,32 @@ public class JobEvent
/// Links Applied/Declined events back to their submit log.
public Guid? ApplicationLogId { get; set; }
+ ///
+ /// Builds a ledger event for a posting the user saw or acted on in a
+ /// results list. The score snapshot is whatever was on screen at the time;
+ /// null means the posting was never scored.
+ ///
+ public static JobEvent ForPosting(
+ JobEventType type,
+ string jobUrl,
+ string jobTitle,
+ string company,
+ string source,
+ GhostScoreSnapshot? ghostScore) => new()
+ {
+ Type = type,
+ JobUrl = jobUrl,
+ JobTitle = jobTitle,
+ Company = company,
+ Source = source,
+ PostingKey = Services.PostingKey.For(jobUrl, company, jobTitle),
+ RiskScore = ghostScore?.RiskScore,
+ RiskBand = ghostScore?.Band,
+ Evidence = ghostScore == null || ghostScore.TopEvidence.Length == 0
+ ? null
+ : string.Join("\n", ghostScore.TopEvidence)
+ };
+
///
/// Maps a finished submit-flow log to its ledger event: a completed submit
/// becomes , a user cancel at the gate
@@ -53,20 +79,9 @@ public class JobEvent
default: return null;
}
- return new JobEvent
- {
- Type = type,
- JobUrl = log.JobUrl,
- JobTitle = log.JobTitle,
- Company = log.Company,
- PostingKey = Services.PostingKey.For(log.JobUrl, log.Company, log.JobTitle),
- RiskScore = ghostScore?.RiskScore,
- RiskBand = ghostScore?.Band,
- Evidence = ghostScore == null || ghostScore.TopEvidence.Length == 0
- ? null
- : string.Join("\n", ghostScore.TopEvidence),
- ApplicationLogId = log.Id
- };
+ var jobEvent = ForPosting(type, log.JobUrl, log.JobTitle, log.Company, source: "", ghostScore);
+ jobEvent.ApplicationLogId = log.Id;
+ return jobEvent;
}
}
diff --git a/src/Envoy.Core/Services/Repositories.cs b/src/Envoy.Core/Services/Repositories.cs
index 900c5b7..d538e4f 100644
--- a/src/Envoy.Core/Services/Repositories.cs
+++ b/src/Envoy.Core/Services/Repositories.cs
@@ -235,6 +235,7 @@ public interface IJobEventRepository
Task AddAsync(JobEvent jobEvent, CancellationToken ct = default);
Task> GetAllAsync(CancellationToken ct = default);
Task CountByTypeAsync(JobEventType type, CancellationToken ct = default);
+ Task RecordSightingsAsync(IReadOnlyList sightings, CancellationToken ct = default);
}
public class JobEventRepository : IJobEventRepository
@@ -271,4 +272,34 @@ public async Task CountByTypeAsync(JobEventType type, CancellationToken ct
.AsNoTracking()
.CountAsync(e => e.Type == type, ct);
}
+
+ // Sightings are deduplicated per posting per UTC day: re-running the same
+ // search minutes later must not inflate the ledger, while a listing seen
+ // again on a later day lands as a new row — that day-by-day trail is the
+ // history the repost-frequency signal wants. Non-Sighted events in the
+ // batch are ignored. Returns the number of rows actually inserted.
+ public async Task RecordSightingsAsync(IReadOnlyList sightings, CancellationToken ct = default)
+ {
+ if (sightings.Count == 0) return 0;
+
+ using var db = _factory.CreateDbContext();
+ var dayStart = DateTime.UtcNow.Date;
+ var seenToday = (await db.JobEvents
+ .AsNoTracking()
+ .Where(e => e.Type == JobEventType.Sighted && e.OccurredAt >= dayStart)
+ .Select(e => e.PostingKey)
+ .ToListAsync(ct)).ToHashSet();
+
+ var fresh = new List();
+ foreach (var sighting in sightings)
+ {
+ if (sighting.Type == JobEventType.Sighted && seenToday.Add(sighting.PostingKey))
+ fresh.Add(sighting);
+ }
+
+ if (fresh.Count == 0) return 0;
+ db.JobEvents.AddRange(fresh);
+ await db.SaveChangesAsync(ct);
+ return fresh.Count;
+ }
}
diff --git a/src/Envoy.UI/FindJobsView.xaml b/src/Envoy.UI/FindJobsView.xaml
index 9a2f4c2..7c7cc46 100644
--- a/src/Envoy.UI/FindJobsView.xaml
+++ b/src/Envoy.UI/FindJobsView.xaml
@@ -144,7 +144,8 @@
-
+
+
diff --git a/src/Envoy.UI/FindJobsView.xaml.cs b/src/Envoy.UI/FindJobsView.xaml.cs
index cd277d3..02e977a 100644
--- a/src/Envoy.UI/FindJobsView.xaml.cs
+++ b/src/Envoy.UI/FindJobsView.xaml.cs
@@ -1,4 +1,6 @@
using Envoy.Core.Configuration;
+using Envoy.Core.Models;
+using Envoy.Core.Services;
using Envoy.Discovery;
using Envoy.Discovery.Models;
using Envoy.GhostDetection;
@@ -16,14 +18,16 @@ public partial class FindJobsView : UserControl
private readonly JobDiscoveryService _discovery;
private readonly GhostScorer _scorer;
private readonly EnvoySettings _settings;
+ private readonly IJobEventRepository _jobEvents;
private List _boards = new();
private List _lastItems = new();
- public FindJobsView(JobDiscoveryService discovery, GhostScorer scorer, EnvoySettings settings)
+ public FindJobsView(JobDiscoveryService discovery, GhostScorer scorer, EnvoySettings settings, IJobEventRepository jobEvents)
{
_discovery = discovery;
_scorer = scorer;
_settings = settings;
+ _jobEvents = jobEvents;
InitializeComponent();
Loaded += FindJobsView_Loaded;
}
@@ -199,6 +203,7 @@ private async Task RenderAsync(DiscoveryResult result)
}
ResultsList.ItemsSource = items;
_lastItems = items;
+ RecordSightings(items);
if (items.Count == 0)
{
@@ -241,10 +246,46 @@ private static DiscoveredJobItem ToItem(JobPosting job, GhostScore score)
RiskBrush = brush,
Evidence = evidence,
EvidenceVisibility = string.IsNullOrEmpty(evidence) ? Visibility.Collapsed : Visibility.Visible,
- Url = job.Url
+ Url = job.Url,
+ Posting = job,
+ Snapshot = new GhostScoreSnapshot(score.RiskScore, score.Band.ToString(), score.TopEvidence)
};
}
+ // Ledger bookkeeping is best-effort and off the UI thread; a bookkeeping
+ // failure must never break the search results on screen.
+ private void RecordSightings(List items)
+ {
+ var sightings = items
+ .Where(i => i.Posting != null)
+ .Select(i => JobEvent.ForPosting(
+ JobEventType.Sighted,
+ i.Posting!.Url, i.Posting.JobTitle, i.Posting.CompanyName,
+ i.Posting.Source.ToString(), i.Snapshot))
+ .ToList();
+ if (sightings.Count == 0) return;
+
+ _ = Task.Run(async () =>
+ {
+ try { await _jobEvents.RecordSightingsAsync(sightings); }
+ catch { /* bookkeeping only */ }
+ });
+ }
+
+ private void RecordItemEvent(DiscoveredJobItem item, JobEventType type)
+ {
+ if (item.Posting == null) return;
+ var jobEvent = JobEvent.ForPosting(
+ type, item.Posting.Url, item.Posting.JobTitle, item.Posting.CompanyName,
+ item.Posting.Source.ToString(), item.Snapshot);
+
+ _ = Task.Run(async () =>
+ {
+ try { await _jobEvents.AddAsync(jobEvent); }
+ catch { /* bookkeeping only */ }
+ });
+ }
+
private void CmbSort_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_lastItems.Count == 0 || CmbSort?.SelectedItem == null) return;
@@ -262,13 +303,33 @@ private void CmbSort_SelectionChanged(object sender, SelectionChangedEventArgs e
private void BtnView_Click(object sender, RoutedEventArgs e)
{
- if (sender is Button btn && btn.Tag is string url && !string.IsNullOrWhiteSpace(url))
+ if (sender is Button btn && btn.Tag is DiscoveredJobItem item && !string.IsNullOrWhiteSpace(item.Url))
{
- try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
+ RecordItemEvent(item, JobEventType.Viewed);
+ try { Process.Start(new ProcessStartInfo(item.Url) { UseShellExecute = true }); }
catch (Exception ex) { ShowError($"Could not open link: {ex.Message}"); }
}
}
+ private void BtnSkip_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button btn || btn.Tag is not DiscoveredJobItem item) return;
+
+ RecordItemEvent(item, JobEventType.Skipped);
+
+ // Drop the row but keep whatever order is currently on screen.
+ _lastItems.Remove(item);
+ var current = (ResultsList.ItemsSource as IEnumerable)?.Where(i => i != item).ToList()
+ ?? _lastItems.ToList();
+ ResultsList.ItemsSource = current;
+
+ var flagged = item.Snapshot?.Band is "High" or "Elevated";
+ StatusText.Text = flagged
+ ? $"✓ GHOST DODGED · {item.RiskText} · {item.Company}"
+ : $"SKIPPED · {item.Company}";
+ StatusText.Foreground = flagged ? Green : Gray;
+ }
+
private void SetBusy(bool busy, string? message = null)
{
BtnScanBoards.IsEnabled = !busy;
@@ -298,6 +359,11 @@ public class DiscoveredJobItem
public string Evidence { get; init; } = "";
public Visibility EvidenceVisibility { get; init; } = Visibility.Collapsed;
public string Url { get; init; } = "";
+
+ // Raw posting + score snapshot so user actions on this row can be recorded
+ // in the ledger with the evidence that was on screen.
+ public JobPosting? Posting { get; init; }
+ public GhostScoreSnapshot? Snapshot { get; init; }
}
public class BoardListItem
diff --git a/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs b/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs
index aea9feb..bd9c24e 100644
--- a/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs
+++ b/tests/Envoy.Core.Tests/JobEventRepositoryTests.cs
@@ -43,18 +43,21 @@ public JobEventRepositoryTests()
public void Dispose() => _connection.Dispose();
- private static JobEvent Event(JobEventType type, DateTime occurredAt) => new()
+ private static JobEvent Event(JobEventType type, DateTime occurredAt, string postingKey = "boards.greenhouse.io/acme/jobs/123") => new()
{
Type = type,
OccurredAt = occurredAt,
JobUrl = "https://boards.greenhouse.io/acme/jobs/123",
JobTitle = "Engineer",
Company = "Acme",
- PostingKey = "boards.greenhouse.io/acme/jobs/123",
+ PostingKey = postingKey,
RiskBand = "High",
RiskScore = 80
};
+ private static JobEvent Sighting(string postingKey, DateTime? occurredAt = null) =>
+ Event(JobEventType.Sighted, occurredAt ?? DateTime.UtcNow, postingKey);
+
[Fact]
public async Task AddAndGetAll_RoundTrips_NewestFirst()
{
@@ -84,4 +87,49 @@ public async Task CountByType_CountsOnlyMatchingEvents()
Assert.Equal(1, await _repo.CountByTypeAsync(JobEventType.Declined));
Assert.Equal(0, await _repo.CountByTypeAsync(JobEventType.Skipped));
}
+
+ [Fact]
+ public async Task RecordSightings_InsertsFresh_SkipsSameDayDuplicates()
+ {
+ var first = await _repo.RecordSightingsAsync(new[]
+ {
+ Sighting("key-a"),
+ Sighting("key-b"),
+ Sighting("key-a") // duplicate inside the same batch
+ });
+ Assert.Equal(2, first);
+
+ var second = await _repo.RecordSightingsAsync(new[]
+ {
+ Sighting("key-a"), // already sighted today
+ Sighting("key-c")
+ });
+ Assert.Equal(1, second);
+
+ Assert.Equal(3, await _repo.CountByTypeAsync(JobEventType.Sighted));
+ }
+
+ [Fact]
+ public async Task RecordSightings_SamePostingOnALaterDay_RecordsAgain()
+ {
+ await _repo.AddAsync(Sighting("key-a", DateTime.UtcNow.AddDays(-1)));
+
+ var inserted = await _repo.RecordSightingsAsync(new[] { Sighting("key-a") });
+
+ Assert.Equal(1, inserted);
+ Assert.Equal(2, await _repo.CountByTypeAsync(JobEventType.Sighted));
+ }
+
+ [Fact]
+ public async Task RecordSightings_IgnoresNonSightedEvents()
+ {
+ var inserted = await _repo.RecordSightingsAsync(new[]
+ {
+ Event(JobEventType.Skipped, DateTime.UtcNow),
+ Event(JobEventType.Viewed, DateTime.UtcNow)
+ });
+
+ Assert.Equal(0, inserted);
+ Assert.Empty(await _repo.GetAllAsync());
+ }
}
diff --git a/tests/Envoy.Core.Tests/JobEventTests.cs b/tests/Envoy.Core.Tests/JobEventTests.cs
index f5b746a..8dfe9f2 100644
--- a/tests/Envoy.Core.Tests/JobEventTests.cs
+++ b/tests/Envoy.Core.Tests/JobEventTests.cs
@@ -55,6 +55,22 @@ public void MachineOutcomes_ProduceNoLedgerEvent(ApplicationStatus status)
Assert.Null(JobEvent.FromApplication(Log(status), Snapshot));
}
+ [Fact]
+ public void ForPosting_BuildsIdentityKeySourceAndScoreFields()
+ {
+ var jobEvent = JobEvent.ForPosting(
+ JobEventType.Skipped,
+ "https://boards.greenhouse.io/acme/jobs/123?utm_source=feed",
+ "Engineer", "Acme", "Greenhouse", Snapshot);
+
+ Assert.Equal(JobEventType.Skipped, jobEvent.Type);
+ Assert.Equal("boards.greenhouse.io/acme/jobs/123", jobEvent.PostingKey);
+ Assert.Equal("Greenhouse", jobEvent.Source);
+ Assert.Equal(72.5, jobEvent.RiskScore);
+ Assert.Equal("High", jobEvent.RiskBand);
+ Assert.Null(jobEvent.ApplicationLogId);
+ }
+
[Fact]
public void UnscoredPosting_LeavesRiskFieldsNull()
{