Grind lives in your menu bar and on your desktop, and answers the only three questions that actually change behaviour:
Am I safe today? · Where am I bleeding? · What do I solve next?
The usual "LeetCode tracker" is a Python/Tk window that shows a solved count and a heatmap. That's a dashboard — nobody changes behaviour because of a dashboard.
Grind is a coaching surface. It escalates urgency as your streak comes under threat, finds the tags you're actually weak in (measured against yourself, not a leaderboard), and hands you one concrete unsolved problem to do next — all without opening a browser.
| Grind does | Grind refuses to |
|---|---|
| GraphQL only, one plausible-header request path | ❌ Scrape HTML |
Native MenuBarExtra + WidgetKit + Swift Charts |
❌ Electron / Python / web views |
| Keychain-only optional session cookie | ❌ Store credentials in plaintext |
| Compare you to your own solved distribution | ❌ Fabricate confidence from thin data |
| Zero third-party dependencies | ❌ Pull in a framework to draw a grid |
The menu bar icon is the state. It shifts from flame → flame.fill → warning triangle as
local midnight approaches, and the notification copy is honest about stakes:
"you're about to drop a 47-day streak," not "reminder."
7×N grid, quantile-based color buckets (readable whether you do 5/day or 50/day), month labels
placed by the week that contains the 1st — never 4.33-weeks-per-month math. UTC→local day
mapping is unit-tested against leap years, DST weeks, and Asia/Kolkata offsets.
Coverage ratio + z-score per tag, computed against your own mean. Surfaces your bottom 5 tags — so it doesn't punish you for a category you deliberately skipped.
One recommended problem: weighted-random over your weakest tags, difficulty adapted to your solve rate, guaranteed unsolved and (by default) non-premium. One click opens it.
Linear regression over your last 10 contests with an ETA to the next threshold. If there isn't
enough signal (< 6 contests or R² < 0.3) it says "not enough signal" instead of lying.
Small / Medium / Large / Notification-Center families, all reading a shared SwiftData cache. The timeline never touches the network and flips streak state exactly at local midnight.
┌──────────────────────────────────────────────────────────────┐
│ GrindApp (LSUIElement · no dock icon) │
│ MenuBarExtra(.window) · Settings · onboarding │
└───────────────┬───────────────────────────────┬──────────────┘
│ embeds │ writes cache
┌───────▼────────┐ ┌────────▼─────────┐
│ GrindKit │ │ App Group │
│ (framework) │◀────reads───│ SwiftData store │
│ │ └────────▲─────────┘
│ Networking ─── GraphQLClient (URLSession, retry, ETag)
│ Models ─────── Codable DTOs + @Model │
│ Store ──────── ProfileStore (actor) refresh orchestr. │
│ Domain ─────── StreakEngine · TagAnalyzer · │
│ Recommender · RatingProjector │
│ UI ─────────── HeatmapView (Canvas) · RingView │
└─────────────────────────────┬──────────────────────────┘
│ embeds (reads cache only)
┌───────▼────────┐
│ GrindWidget │
│ (WidgetKit) │
└────────────────┘
GrindKit— the shared framework, embedded in both the app and the widget. All domain logic is pure and testable with zero network in tests (recorded JSON fixtures).- XcodeGen owns the project.
project.ymlis the single source of truth; the.pbxprojis generated and git-ignored. Never hand-edited.
- macOS 14.0 (Sonoma) or later — desktop widgets need it
- Full Xcode.app (the Command Line Tools alone can't build app/widget targets)
- XcodeGen —
brew install xcodegen
make gen # generate Grind.xcodeproj from project.yml
make build # build the app (Debug)
make run # launch it — a flame appears in your menu bar
make test # run the GrindKit suite (no network)Run make help to see every target.
Note
If make build reports xcodebuild: error: ... requires Xcode, you only have the Command
Line Tools installed. Install Xcode from the App Store, then:
sudo xcode-select -s /Applications/Xcode.app/Contents/DeveloperImportant
Widgets + App Group without a paid team. The widget reads the app's SwiftData cache
through the App Group group.com.grind.shared. Adding that entitlement normally makes Xcode
demand a provisioning profile — so the checked-in build entitlements omit it, and make build
re-signs the app + widget ad-hoc with the group entitlement afterwards (make sign-groups).
This works on macOS with no signing team. With a real DEVELOPMENT_TEAM, point
CODE_SIGN_ENTITLEMENTS at the *.groups.entitlements files and delete the re-sign step.
Note: the accessory widget families (accessoryRectangular, …) are iOS/watchOS-only — on
macOS the three system sizes also serve Notification Center, so that's what ships.
The menu bar popover — streak header, solved rings, today's daily, a tag-targeted Next-up recommendation, weak tags, a 26-week heatmap, and the contest rating ETA. Live data, real account.
Settings — Account · Appearance · Alerts · Advanced (theme, heatmap range/color, launch-at-login, notifications, session cookie, reset cache).
LeetCode heatmap style (Settings → Appearance → Heatmap style) — the exact leetcode.com layout: each month is its own block (the 1st placed at its weekday, partial weeks padded, a gap between months), fixed count-based green levels, and a horizontal slider across the year.
Widgets (
systemSmall/systemMedium/systemLarge) read the same shared cache — add them via right-click desktop → Edit Widgets → search "Grind".
Milestones ship in order; each must build and test green (see SPEC.md §11).
- M0 —
project.yml, Makefile, three targets, menu bar app scaffold - M1 —
GraphQLClient+ DTOs + 7 real fixtures + decode tests - M2 —
StreakEngine+HeatmapView+ full timezone/leap/DST suite - M3 — popover wired to live data + username onboarding
- M4 — SwiftData App Group cache + widget families (3 macOS system sizes)
- M5 —
TagAnalyzer+Recommender+ Next-up card - M6 — contest module +
RatingProjector+ honest ETA - M7 — notifications, Settings, launch-at-login, Keychain cookie
Local dev uses ad-hoc signing so the app builds without a paid team. To hand the app to someone else, sign + notarize:
# 1. Sign with your Developer ID and the hardened runtime
codesign --force --deep --options runtime \
--sign "Developer ID Application: Your Name (TEAMID)" \
build/Build/Products/Release/Grind.app
# 2. Notarize (requires an App Store Connect API key / app-specific password)
xcrun notarytool submit Grind.zip \
--apple-id you@example.com --team-id TEAMID --wait
# 3. Staple the ticket
xcrun stapler staple build/Build/Products/Release/Grind.appNotarization is a manual, ship-time step — never run it in CI.
Grind/
├── project.yml # XcodeGen manifest — the source of truth
├── Makefile # gen / build / test / run / lint / clean
├── SPEC.md # the full product + engineering spec
├── Sources/
│ ├── GrindKit/ # shared framework (Networking · Models · Store · Domain · UI)
│ ├── GrindApp/ # MenuBarExtra + Settings + onboarding
│ └── GrindWidget/ # WidgetKit extension
└── Tests/
├── GrindKitTests/ # pure unit tests, zero network
└── Fixtures/ # recorded GraphQL JSON responses