Skip to content

Desloppify/code health - #1

Merged
malware-dev merged 9 commits into
mainfrom
desloppify/code-health
May 26, 2026
Merged

Desloppify/code health#1
malware-dev merged 9 commits into
mainfrom
desloppify/code-health

Conversation

@malware-dev

Copy link
Copy Markdown
Collaborator

No description provided.

 - 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.
@malware-dev
malware-dev merged commit 5012fe4 into main May 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant