From d39a13f6b51a8d267e8b67683fbc083eeae42a59 Mon Sep 17 00:00:00 2001 From: LXBStudioLLC Date: Sat, 18 Jul 2026 15:12:22 -0400 Subject: [PATCH] feat(ui): Find Jobs feeds the ledger with sightings, views, and a skip gesture Every scored search result is recorded as a Sighted event, deduplicated per posting per UTC day so re-running a search inflates nothing. The day-by-day trail this builds is the cross-session listing history the repost-frequency signal needs for its stronger path. Opening a posting records a Viewed event. A new SKIP button on each result row records a Skipped event with the score snapshot that was on screen, removes the row, and answers with GHOST DODGED in the status line when the posting was flagged. That skip is the raw event behind the scoreboard's signature stat. JobEvent.ForPosting is the shared factory for list-side events; FromApplication now delegates to it. Ledger writes from the view are fire-and-forget off the UI thread and can never break a search. --- CHANGELOG.md | 1 + src/Envoy.Core/Models/JobEvent.cs | 43 +++++++---- src/Envoy.Core/Services/Repositories.cs | 31 ++++++++ src/Envoy.UI/FindJobsView.xaml | 3 +- src/Envoy.UI/FindJobsView.xaml.cs | 74 ++++++++++++++++++- .../JobEventRepositoryTests.cs | 52 ++++++++++++- tests/Envoy.Core.Tests/JobEventTests.cs | 16 ++++ 7 files changed, 199 insertions(+), 21 deletions(-) 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 @@ -