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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,16 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle
**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch:
- **Workspace onboarding + tool legend, and a warmer Home (added 2026-09-02, on branch `feature/workspace-onboarding`, not yet merged to staging):** a "Tool Help" button (flyout, always available) now explains all four canvas tools including Select's three click-target-dependent behaviors. A skippable first-run coach-mark tour (`TeachingTip`, one per tool button, Next/Skip actions) walks a new user through them once — tracked via a new `AppSettings.HasSeenWorkspaceOnboarding` (same `IAppSettingsService` store the update-check feature added), `WorkspaceViewModel.ShouldShowOnboardingAsync`/`MarkOnboardingSeenAsync`. Separately, at the user's request, Home's static "Home" title is now a time-of-day greeting (`HomeViewModel.GreetingDisplay`, morning/afternoon/evening/late-night buckets on `DateTime.Now.Hour`) plus a small, non-interactive `WellbeingMessageDisplay` line picked at random from 4 options each visit — no reply affordance, just a quiet touch. The now-unused static "HomeTitle" resource was removed from both languages since this change made it dead.
- **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing.

**WLAN Link Diagnostics — "Swiss army knife" troubleshooting (added 2026-09-02, on branch `feature/diagnostics-and-polish`, not yet merged to staging):** per the user's own framing — "why on cable I have 100-150mbps, on wlan just a few KBPS" — a generic troubleshooting tool that never connects to or configures any network device, only reads this machine's own adapter state and does local checks (gateway ping, DNS timing), the same category of operation the existing WLAN scan already performs. Landed as one branch covering both halves the user asked for:
- **Core:** `LinkDiagnosticsSnapshot`/`NetworkHealthSnapshot` (Abstractions), `ILinkDiagnosticsService`/`INetworkHealthService` interfaces, and `Diagnostics/LinkDiagnosticsAnalyzer` — pure heuristics (weak signal by the same -67dBm-equivalent threshold `GreedyCoverageApPlacementOptimizer` already uses expressed on `wlanSignalQuality`'s percent scale, a legacy-PHY-rate fallback on 5/6GHz, high gateway ping, DNS failure/slowness, and — only when a predicted dBm is supplied — a predicted-vs-actual mismatch) fully unit tested with zero I/O. `CoverageGridCalculator`'s per-point inner loop was extracted (behavior-preserving) into a public `StrongestSignalDbm(point, floor, allFloors, band, propagationModel)` so both the coverage grid and Workspace's new single-point diagnostics comparison share the exact same cross-floor/outdoor skip rules and never disagree.
- **App, Phase 1 (standalone):** `NativeWlan` gained `WlanQueryInterface` plus the real `WLAN_CONNECTION_ATTRIBUTES`/`WLAN_ASSOCIATION_ATTRIBUTES`/`WLAN_SECURITY_ATTRIBUTES` structs and three small enums, struct layout and opcode values (`wlan_intf_opcode_current_connection` = 7, `wlan_intf_opcode_channel_number` = 8) verified live against Microsoft Learn's wlanapi.h reference rather than assumed, per this file's own documented discipline for this interop file; enum members are named without their native `wlan_..._` prefix (CA1712) with the native name kept in a same-line comment. `ulRxRate`/`ulTxRate`'s undocumented unit was confirmed as plain Kbps (not the 100Kbps unit some other wlanapi rate fields use) against the community `ManagedNativeWifi` wrapper's real-world-validated usage, since Microsoft's own docs state no unit at all. Band classification from a live connection is a documented best-effort: VHT/OFDM PHY types are unambiguous (5GHz-only in practice), other PHY types fall back to channel-number ranges that are only reliable outside 6GHz's numeric overlap with 2.4GHz's 1-14 range (real disambiguation would need a center frequency, which this opcode doesn't expose). New `WlanLinkDiagnosticsService`/`NetworkHealthService` (the latter pure `System.Net.NetworkInformation`, no native surface), `DiagnosticsViewModel` (adapter picker reusing `IWlanAdapterService`, a one-shot "Run diagnostics" plus a `PeriodicTimer`-based continuous-monitoring toggle, 5s interval), new `DiagnosticsPage` and a "Diagnose" nav item. Severity color-coding intentionally lives in a small `DiagnosticSeverityToBrushConverter` (resolving to App.xaml's `DangerBrush`/`WarningBrush`/`SuccessBrush` design tokens) rather than a computed `Brush` property on the ViewModel-layer display record — the first attempt did the latter and broke instantly in a plain xunit test with a null `Application.Current`, since nothing else in this app's ViewModel layer had ever touched a WinUI type directly.
- **App, Phase 2 (Workspace integration):** a 5th toolbar tool, `WorkspaceTool.Diagnose` (not folded into the already-overloaded Select tool, per the UX review's own finding about Select), fires `FloorPlanCanvas.DiagnosePositionRequested` on click; `WorkspaceViewModel.DiagnoseAtPointAsync` combines a live link+health read with `CoverageGridCalculator.StrongestSignalDbm` at that point and runs both through the same `LinkDiagnosticsAnalyzer`, surfaced as a `Flyout` anchored at the actual click position (`FloorPlanCanvas.ToScreenPoint`, a new small public wrapper around its existing private meters-to-pixels math) rather than a new docked panel, keeping the existing 3-tab side panel untouched. Tool Help legend gained a 5th entry.
- 104 App tests / 100 Core tests total on this branch, including the `StrongestSignalDbm` extraction's own regression test and full `LinkDiagnosticsAnalyzerTests` coverage.

**Remaining UX-review polish items, same branch (added 2026-09-02):** closes out the last four friction points from the full-codebase UX review.
- **Suggest-Placements silent replace:** running it a second time on a floor with existing suggested (not user-placed) access points used to discard them with zero warning. `SuggestPlacementsButton`'s `Command` binding became a `Click` handler (`SuggestPlacements_Click`) that checks the new `WorkspaceViewModel.HasReplaceableSuggestions` (true only when the floor has any `AccessPoint` with `IsUserOverride == false`) and shows a `ContentDialog` confirm only when there's actually something to lose.
- **Survey-type context in Workspace:** `WorkspaceViewModel.SurveyTypeDisplay` reuses the exact same `WizardSurveyTypeSummary*` localization keys the wizard's own final step already had, shown as a new subtitle line under the survey name — nothing in Workspace previously reminded a user which mode ("new deployment" vs. "existing network audit", with SSID) their survey was in once they'd left the wizard.
- **Heatmap-cell explanation:** the Coverage tab's static color legend already existed; the actual gap was a specific cell's exact predicted dBm. `FloorPlanCanvas` gained a small floating tooltip (`TooltipLayer`/`HeatmapTooltip`, a `Border`+`TextBlock` positioned via `Canvas.SetLeft/Top`, not `ToolTipService` — avoids that API's own auto-show/hide fighting with manually-driven content) that follows the pointer in `RootGrid_PointerMoved` and shows the nearest heatmap sample's dBm value whenever the pointer is within `HeatmapCellMeters * 0.75` of it, hidden again on `PointerExited`.
- **Snapshot delete:** `SnapshotComparisonPage` had no way to remove a saved snapshot at all. `SnapshotComparisonViewModel` now takes `ISurveyFileService` and the survey's file path (`Initialize(Survey, string filePath)` — the page's navigation parameter changed from a bare `Survey` to `(Survey, string)`, sourced from a new `WorkspaceViewModel.FilePath`), with `DeleteLeftSnapshotCommand`/`DeleteRightSnapshotCommand` (enabled only when that side's picker has an actual snapshot selected, not "Current") persisting the removal and refreshing both sides' options — the side that didn't just delete keeps its own selection unless that was the same snapshot. A `ContentDialog` confirms first, matching this app's established delete-confirmation pattern (Surveys page, Delete tool).
- 113 App tests / 100 Core tests total after these four.
8 changes: 8 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/ILinkDiagnosticsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>Platform-specific live WLAN link state, implemented in Pulsemap.App via native
/// wlanapi.dll P/Invoke (WlanQueryInterface) — same split as <see cref="IWlanAdapterService"/>.</summary>
public interface ILinkDiagnosticsService
{
Task<LinkDiagnosticsSnapshot> GetCurrentLinkAsync(Guid adapterId, CancellationToken cancellationToken = default);
}
9 changes: 9 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/INetworkHealthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>Local network health checks (gateway ping, DNS timing) for the given adapter. Pure BCL
/// networking (System.Net.NetworkInformation) — no native P/Invoke needed, unlike the WLAN-specific
/// abstractions in this folder.</summary>
public interface INetworkHealthService
{
Task<NetworkHealthSnapshot> CheckHealthAsync(Guid adapterId, CancellationToken cancellationToken = default);
}
20 changes: 20 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/LinkDiagnosticsSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Pulsemap.App.Core.Models;

namespace Pulsemap.App.Core.Abstractions;

/// <summary>A point-in-time reading of this machine's current WLAN association — live link state,
/// not a scan of nearby networks (that's <see cref="WlanScanResult"/>). <paramref name="RxLinkSpeedMbps"/>/
/// <paramref name="TxLinkSpeedMbps"/> are the negotiated PHY rate, not throughput actually achieved.</summary>
public sealed record LinkDiagnosticsSnapshot(
bool IsConnected,
string? Ssid,
string? Bssid,
Band? Band,
int Channel,
int SignalPercent,
string? PhyType,
double RxLinkSpeedMbps,
double TxLinkSpeedMbps)
{
public static LinkDiagnosticsSnapshot Disconnected { get; } = new(false, null, null, null, 0, 0, null, 0, 0);
}
10 changes: 10 additions & 0 deletions src/Pulsemap.App.Core/Abstractions/NetworkHealthSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Pulsemap.App.Core.Abstractions;

/// <summary>Local network health independent of the WLAN link itself — isolates "it's not your WiFi,
/// it's upstream" from link-quality problems. Null timing fields mean the corresponding check
/// couldn't run at all (no gateway found, no network path), distinct from a check that ran and
/// found a problem (<see cref="DnsSucceeded"/> false, or a very high ping).</summary>
public sealed record NetworkHealthSnapshot(string? GatewayAddress, double? GatewayPingMs, double? DnsResolutionMs, bool DnsSucceeded)
{
public static NetworkHealthSnapshot Unavailable { get; } = new(null, null, null, false);
}
7 changes: 7 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/DiagnosticFinding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Pulsemap.App.Core.Diagnostics;

/// <summary>One troubleshooting observation. <paramref name="MessageKey"/> is a localization key,
/// not display text — <see cref="FormatArgs"/> are passed to the App layer's
/// <c>string.Format</c>/<c>ILocalizationService</c> call so this stays free of any UI/culture
/// concerns, matching how the rest of Core hands display strings back as keys.</summary>
public sealed record DiagnosticFinding(DiagnosticSeverity Severity, string MessageKey, IReadOnlyList<object>? FormatArgs = null);
8 changes: 8 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/DiagnosticSeverity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Pulsemap.App.Core.Diagnostics;

public enum DiagnosticSeverity
{
Info,
Warning,
Error,
}
118 changes: 118 additions & 0 deletions src/Pulsemap.App.Core/Diagnostics/LinkDiagnosticsAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using Pulsemap.App.Core.Abstractions;
using Pulsemap.App.Core.Models;

namespace Pulsemap.App.Core.Diagnostics;

/// <summary>Pure heuristics turning a link/network snapshot into plain-language troubleshooting
/// findings — no I/O, so this is where the "why is WiFi slow" reasoning actually lives and can be
/// fully unit tested, independent of the native WLAN calls that produce its inputs.</summary>
public static class LinkDiagnosticsAnalyzer
{
// Same -67dBm "reliable data" convention as GreedyCoverageApPlacementOptimizer, expressed in
// WLAN_ASSOCIATION_ATTRIBUTES.wlanSignalQuality's percent scale via its own documented linear
// mapping (0% = -100dBm, 100% = -50dBm): percent = (dBm + 100) / 0.5.
private const int WeakSignalPercentThreshold = 66;
private const int VeryWeakSignalPercentThreshold = 33;

// A link stuck at or below this negotiated rate on 5GHz/6GHz strongly implies a fallback to a
// legacy/compatibility PHY rate rather than a real 802.11n/ac/ax/be link — a classic cause of
// "fast WAN, painfully slow WiFi" that signal strength alone wouldn't explain.
private const double LowPhyRateMbpsOn5Or6Ghz = 11;

private const double HighGatewayPingMs = 100;
private const double SlowDnsResolutionMs = 150;

// How far below the predicted signal an actual reading has to fall before it's flagged as a
// real mismatch rather than ordinary measurement/model noise (Phase 2 only).
private const double PredictedVsActualMismatchDb = 15;

public static IReadOnlyList<DiagnosticFinding> Analyze(LinkDiagnosticsSnapshot link, NetworkHealthSnapshot? health, double? predictedSignalDbm = null)
{
ArgumentNullException.ThrowIfNull(link);

if (!link.IsConnected)
{
return [new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticNotConnected")];
}

var findings = new List<DiagnosticFinding>();

AnalyzeSignal(link, findings);
AnalyzePhyRate(link, findings);
AnalyzeNetworkHealth(health, findings);
AnalyzePredictedVsActual(link, predictedSignalDbm, findings);

if (findings.Count == 0)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Info, "DiagnosticNoIssuesFound"));
}

return findings;
}

private static void AnalyzeSignal(LinkDiagnosticsSnapshot link, List<DiagnosticFinding> findings)
{
if (link.SignalPercent <= VeryWeakSignalPercentThreshold)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticVeryWeakSignal", [link.SignalPercent]));
}
else if (link.SignalPercent <= WeakSignalPercentThreshold)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticWeakSignal", [link.SignalPercent]));
}
}

private static void AnalyzePhyRate(LinkDiagnosticsSnapshot link, List<DiagnosticFinding> findings)
{
if (link.Band is Band.FiveGhz or Band.SixGhz && link.RxLinkSpeedMbps > 0 && link.RxLinkSpeedMbps <= LowPhyRateMbpsOn5Or6Ghz)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticLegacyPhyRate", [link.RxLinkSpeedMbps]));
}
}

private static void AnalyzeNetworkHealth(NetworkHealthSnapshot? health, List<DiagnosticFinding> findings)
{
if (health is null)
{
return;
}

if (!health.DnsSucceeded)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticDnsFailed"));
}
else if (health.DnsResolutionMs is { } dnsMs && dnsMs >= SlowDnsResolutionMs)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticSlowDns", [dnsMs]));
}

if (health.GatewayPingMs is { } pingMs)
{
if (pingMs >= HighGatewayPingMs)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticHighGatewayPing", [pingMs]));
}
}
else if (health.GatewayAddress is not null)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Error, "DiagnosticGatewayUnreachable"));
}
}

private static void AnalyzePredictedVsActual(LinkDiagnosticsSnapshot link, double? predictedSignalDbm, List<DiagnosticFinding> findings)
{
if (predictedSignalDbm is not { } predicted)
{
return;
}

double actualDbm = PercentToDbm(link.SignalPercent);
double gap = predicted - actualDbm;
if (gap >= PredictedVsActualMismatchDb)
{
findings.Add(new DiagnosticFinding(DiagnosticSeverity.Warning, "DiagnosticPredictedVsActualMismatch", [Math.Round(predicted, 1), Math.Round(actualDbm, 1)]));
}
}

private static double PercentToDbm(int signalPercent) => -100 + (signalPercent * 0.5);
}
Loading
Loading