Desloppify/code health - #1
Merged
Merged
Conversation
- The daily goal is now a count of completed standing cycles
rather than a number of standing minutes. Settings field is
"Daily standing cycles"; outer ring and Today stat track
cycle progress, with minutes-stood-today shown alongside.
- Streak follows the new model: a streak day is an active day
that hit the cycle count. Locked-all-day / paused days are
paused (neither hit nor miss). The streak fails at end-of-day
if an active day finished below the goal — no mid-day instant
loss, just discrete progress.
- Cycles only count toward the goal when ended naturally.
Bailing via Swap leaves you needing more cycles to hit the
goal; Reset just extends the current cycle.
- Date attribution by StartedAt — a cycle that bridges midnight
belongs to the day you decided to stand.
- Background update checks now run on every cycle start
(throttled to at most once per 30 minutes), so long-running
sessions catch new releases without restarting.
- Stats tab: the 13-week minutes heatmap is now half-width
(label below the grid), balanced by a new 14-day cycles-per-
day bar chart on the right.
- Main-disk dashboard button: cog → stats glyph so it doesn't
hide the fact that there's actual data behind it.
- Lifetime achievements (Centurion / LongHaul / Mountaineer)
unchanged — they're still honest standing-time totals.
- Old DailyGoalMinutes settings are ignored on load; new
installs / upgrades get the default of 3 cycles per day.
Files are now grouped by what they do instead of by MVVM layer. Each folder under Features/ holds the views, view-models, models, and services that constitute one feature; Core/ holds cross-cutting MVVM infrastructure and behaviors. Namespaces mirror folders. No behavior change — pure file move + namespace/using/xmlns rewiring. All 28 file moves are tracked as renames so diff history is preserved per file.
Driven by a desloppify subjective-review pass; resolves 17 findings. OverlayViewModel: remove dead Settings/Heatmap surface (ShowSettings, ShowHeatmap, DailyGoalMinutes, StandingTime, SittingTime, StartWithWindows, HeatmapData, and the corresponding OverlayType enum values) — the settings and heatmap UI now lives only in the Dashboard window. Collapse the AdjustHours/AdjustMinutes/AdjustSeconds triple into a single AdjustedTime TimeSpan, eliminating the 0–23 vs TotalHours ambiguity at the seam. IStartupService: move LoginItemLabel from the static StartupService gateway into the interface so each platform owns its own label. Change SetEnabled to return bool; callers (DashboardViewModel.StartWithWindows, PromptStartWithWindows) now persist (SetEnabled(value) && value) so state.json reflects what the OS actually did, not the user's intent. IHistoryStore.GetRange: document as inclusive on both bounds (matches the impl) and fix the two callers that passed today.AddDays(1) under the wrong assumption. UpdateService: rename static CheckInBackground to instance RequestBackgroundCheck; the "Request" verb + instance shape make the fire-and-forget intent explicit. Add XML docs to CheckAsync, RequestBackgroundCheck, and ApplyAndRestart so the three shapes' purposes are unambiguous. MainWindowViewModel: add _disposed guard for IDisposable idempotence, rename `private async void Trigger()` to `private async Task TriggerAsync()` with `_ = TriggerAsync()` at the one call site so fire-and-forget is explicit at the caller rather than hidden in the signature. Misc: remove restating comments in MainWindow.OnDataContextChanged and AppState.LoadState; replace the second AppState catch-comment with a substantive note on why corrupt-file fallback is safe.
Cross-cutting persisted state moves out of the dominant-consumer heuristic and into a shared layer: - AppState moves from Features/Shell to Core/State. Multiple features (Shell, Dashboard, History) mutate or read from it; placing it in one feature forced the others to import upward. Now everything depends top-down on Core for persisted state. Side effect: the 7-file Shell↔History↔Dashboard import cycle dissolves. - ProgressRings moves from Features/Shell to Features/Stats, joining Heatmap and CyclesPerDayBars. All three are render-only Avalonia Controls — same artifact-kind, same folder. MainWindow.axaml's xmlns:controls now points to Lazybones.Features.Stats, matching the convention DashboardWindow.axaml already used. HistoryStore.cs, DashboardViewModel.cs, and MainWindowViewModel.cs swap their using directives to point at the new namespaces; nothing else changes about their behavior.
csproj: - Drop the unused Dotnet.Bundle 0.9.13 reference; the release workflow has used Velopack's `vpk pack` for the macOS .app for a while, no BundleApp target is invoked anywhere. Update the CFBundle comment to point at vpk pack instead. - Bump Avalonia.Diagnostics 11.3.15 → 11.3.16 (latest 11.x). The 12.x line of Diagnostics doesn't exist on NuGet yet; document that in a comment so the drift is intentional rather than accidental. - Fix a leftover TargetFramework condition that read `net8.0-...` — it never matched after the .NET 10 bump, silently dropping ApplicationManifest and ApplicationIcon from Windows builds. Achievements: rename Achievement.cs to AchievementCatalog.cs. The file is 45/51 lines of AchievementCatalog static data; the single-line Achievement record was the minor type. MainWindowViewModel: - TryToggle → ConfirmToggle, TryReset → ConfirmReset. They show a confirmation dialog and act on the result, not the C# Try-pattern. - Extract `pool[Random.Shared.Next(pool.Length)]` (6 call sites) into a static PickRandom helper.
…ions Lifecycle and locking: - ProgressRings.DrawRing: move fillPen allocation past the full-ring early-return — it was unreachable in that branch. - MainWindowViewModel.Dispose: unsubscribe from UpdateService.PropertyChanged (matched MacUserPresenceMonitor's pattern; UpdateService is a singleton, so the missing unsubscribe held the VM alive after window close). - DashboardViewModel: implement IDisposable with idempotent unsubscribe; DashboardWindow.OnClosed disposes the VM. Closes the symmetric leak. - HistoryStore: every read method now takes _lock for the full enumeration. Append was already locked; the reads weren't, so a concurrent Append could throw "collection modified" on the readers. - HistoryStore.LoadAll: narrow catch from bare to JsonException only, so per-record JSON corruption is tolerated but disk/permission errors bubble up instead of being silently swallowed. Write-path error handling: - AppState.GetDataDir backed by Lazy<string>; resolution + the one-time StandUp→Lazybones Directory.Move runs exactly once per process under the Lazy initializer's lock instead of on every getter call. - AppState.SaveState wrapped in try/catch(IOException or UnauthorizedAccessException), matching LoadState's tolerance — a failed tick or shutdown save can no longer crash the app. - HistoryStore.Append wrapped in the same try/catch — losing one cycle record is preferable to crashing on transient I/O. - WindowsStartupService and MacStartupService: bare `catch` blocks on IsEnabled/SetEnabled narrowed to typed catches (SecurityException/UnauthorizedAccessException/IOException). - UpdateService.FetchReleaseNotesAsync bare catch narrowed to HttpRequestException/JsonException/TaskCanceledException. UpdateService aligns with ViewModelBase: - Extract a virtual RaisePropertyChanged from ViewModelBase that SetField and OnPropertyChanged both delegate to. - UpdateService now extends ViewModelBase and overrides only RaisePropertyChanged to add the Dispatcher.UIThread marshalling that background update checks need. Drops ~16 lines of duplicated SetField/PropertyChanged glue and brings UpdateService in line with every other INPC class in the codebase. IHistoryStore explicit clock + bucketing contract: - GetTodayStandingMinutes() and GetTodayStandingCycles() renamed to StandingMinutesOn(DateOnly day) and CompletedStandingCyclesOn( DateOnly day). Callers (MainWindowViewModel, DashboardViewModel, AchievementRules.EvalContext) pass today explicitly instead of the store reading DateTime.Now itself. - XML docs on the interface spell out the bucketing difference: minutes are EndedAt-attributed (when did the minutes accrue), cycles are StartedAt-attributed (when did the user commit). The asymmetry is intentional; the docs now make it visible. Overlay dedup and contract docs: - OverlayViewModel.Confirm and Cancel share their tail (capture-callback, hide, post, drain) — extracted into a private Dismiss(bool accepted) helper. - UpdateService.CheckAsync XML doc now describes the dev-build no-op; ApplyAndRestart XML doc describes the "no pending update" no-op with a pointer to CheckAsync/RequestBackgroundCheck. - IStartupService.SetEnabled XML doc updated to describe the actual `(SetEnabled(value) && value)` caller pattern, replacing the "callers should re-read IsEnabled" promise that wasn't kept.
The com.malforge.standup identifier was a leftover from the project's
previous name. Renaming to com.malforge.lazybones brings the macOS
identity in line with the rest of the project.
- Lazybones.csproj: CFBundleIdentifier updated.
- .github/workflows/release.yml: vpk pack --bundleId updated for both
the osx-arm64 and osx-x64 matrix entries.
- MacStartupService:
- Label const now "com.malforge.lazybones".
- Added LegacyLabel = "com.malforge.standup" and proactively delete
the old plist on every SetEnabled call. Mirrors how
WindowsStartupService cleans up the legacy "StandUp" Run-key
entry.
- IsEnabled returns true if either plist exists, so existing-install
users see the toggle in the correct state during the transition.
On their next toggle, the legacy plist is removed and the new one
written (or removed, if they're disabling).
Five high-value pure-logic targets the desloppify review pass called out as testable get coverage. 85 tests, all passing. New project: Lazybones.Tests (xUnit 2.9, net10.0, cross-platform). Added to Lazybones.sln. .github/workflows/release.yml gets a new `test` job that runs `dotnet test` on ubuntu-latest, gated as a prerequisite for the pack matrix — releases will fail if tests fail. Test files mirror the production tree: - Features/Shell/TimeInputParserTests.cs — 18 tests covering HH:MM:SS and MM:SS happy/invalid paths, suffix forms (h/hr/hrs/hours, m/min/mins/minutes, s/sec/secs/second) with longest-suffix-wins, plain numbers as minutes, comma/dot decimals, positive/negative deltas, underflow clamp to zero, the documented double-sign recursion behavior, trim and case insensitivity. - Features/History/StreakCalculatorTests.cs — 10 tests covering zero/negative-goal guard, today-pending semantics, consecutive-at- goal chaining, active-below-goal breaks, paused-day skipping, sitting-only day breaks, toggled cycles excluded from goal count, StartedAt midnight-boundary attribution, year-lookback cap. - Features/History/HistoryStoreTests.cs — 11 tests using a temp-dir filePath ctor: empty-file behavior, append creates directory, round-trip of all CycleRecord fields, corrupt-line skip while valid records load, blank-line tolerance, GetDay's EndedAt filtering, GetRange inclusive on both bounds, StandingMinutesOn sums correctly, CompletedStandingCyclesOn attribution and outcome filtering. - Features/History/FakeHistoryStore.cs — in-memory IHistoryStore implementation shared by streak and achievement tests. - Features/Achievements/AchievementRulesTests.cs — 18 tests pinning the 16-way predicate switch: FirstStand happy/sitting/toggled/ already-owned/engagement-window, QuickDraw <=10s boundary, IronLegs duration+dismissal interaction, EarlyBird/NightOwl hour boundaries, DailyDriver=5, Overachiever 1.5x, DoubleDown 2x, PerfectDay goal+no-dismissals, PerfectDay blocked by any dismissal, Centurion/LongHaul lifetime thresholds, WarmingUp 3-day streak. - Core/Mvvm/ViewModelBaseTests.cs — 4 tests for SetField raise/skip, OnPropertyChanged passthrough, and the new virtual RaisePropertyChanged override hook used by UpdateService. - Core/Mvvm/RelayCommandTests.cs — 3 tests pinning the current Execute, CanExecute-always-true, and event-no-op shape.
Three pieces shipping together. AppState becomes testable without changing its existing callers: - New public static LoadFrom(filePath) and instance SaveTo(filePath); the existing parameterless LoadState/SaveState delegate to them. - New pure internal ResolveDataDir(customDirOverride, appDataRoot) that takes its environmental inputs as parameters. The Lazy<string> at module init wraps it with the real env / SpecialFolder lookups; tests call the pure method directly. - Lazybones.csproj adds <InternalsVisibleTo Include="Lazybones.Tests"/>. - Lazybones.Tests/Core/State/AppStateTests.cs (12 tests): defaults on missing/corrupt/empty state.json, full-fidelity round-trip, SaveTo creates missing directories with no .tmp leftover, the full ResolveDataDir matrix — custom override wins (even over appdata), empty override falls through, new-dir picked when neither exists, no-migration when new-dir already present, StandUp→Lazybones migration including file content carrying over. macOS bundle identifier moves com → dev: - com.malforge.lazybones → dev.malforge.lazybones in Lazybones.csproj CFBundleIdentifier and in release.yml --bundleId (both osx-arm64 and osx-x64 matrix rows). - MacStartupService.Label updated. The legacy-plist cleanup now walks a list LegacyLabels[] holding every previously-shipped/considered id (com.malforge.standup, com.malforge.lazybones) so users from any prior version end up registered under the current label after one toggle, and IsEnabled returns true if any of those plists exists so the toggle reflects existing-install state during the transition. Release: PackageVersion.txt → 1.0.3, ReleaseNotes.txt gets a v.1.0.3 entry covering the user-visible changes from this branch — I/O resilience on saves, the Dashboard subscription leak fix, the macOS identifier rename with legacy-plist cleanup, and the cleanup/tests pass that landed across the earlier commits on this branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.