diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3e9ec678..d475c94d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,8 +17,8 @@ import type { SearchHit } from './types' import { loadProFeaturesRenderer } from './bootstrap/loadProFeaturesRenderer' import { renderProView, type ProViewContext } from './bootstrap/proView' import { UpgradeScreen } from './components/pro/UpgradeScreen' -import { getProFeature, proFeatureComingSoon } from './components/pro/proCatalog' -import { currentPlatform, isMac } from './lib/device' +import { getProFeature, proFeatureComingSoon, landingView } from './components/pro/proCatalog' +import { currentPlatform } from './lib/device' import { NotificationProvider } from './hooks/NotificationProvider' import { useNotifications } from './hooks/useNotifications' import { ToastProvider } from './hooks/ToastProvider' @@ -226,9 +226,10 @@ function AppContent() { } }, []) - // Free users land on Models (download a model first, with the sidebar to - // explore); Mac Pro users land on Day. Never land on a locked or unavailable tab. - const [viewMode, setViewMode] = useState(isPro && isMac() ? 'day' : 'models') + // Where to open: derived from the per-feature capability seam (see landingView), so + // the landing screen can never disagree with nav and gating about whether Day is + // available on this platform. + const [viewMode, setViewMode] = useState(landingView(currentPlatform(), isPro)) const [selectedSessionId, setSelectedSessionId] = useState(null) const [selectedMemoryId, setSelectedMemoryId] = useState(null) // Version of a downloaded-and-staged update (null = none). Surfaced as a banner diff --git a/src/renderer/src/components/pro/proCatalog.ts b/src/renderer/src/components/pro/proCatalog.ts index 9a670272..35012cb2 100644 --- a/src/renderer/src/components/pro/proCatalog.ts +++ b/src/renderer/src/components/pro/proCatalog.ts @@ -62,7 +62,13 @@ export const PRO_FEATURES: ProFeature[] = [ 'Per-meeting prep: who’s in it and your open items', 'Priorities surfaced from what you actually did' ], - platforms: ['darwin'] + // Ported to Windows: the whole Day path is portable - day.ts, day-layout.ts, + // ahead.ts and calendar.ts carry no platform-native code. Its two data sources + // both work on Windows now: the calendar comes from connectors (HTTP), and the + // activity half reads observations, which Replay's port put on Windows. Landing + // on Day now routes through `landingView` rather than an `isMac()` check, so nav, + // gating, copy and the landing screen all agree. + platforms: ['darwin', 'win32'] }, { route: 'reflect', @@ -161,7 +167,12 @@ export const PRO_FEATURES: ProFeature[] = [ 'Approval queue for actions', 'Auto-extracted to-dos' ], - platforms: ['darwin'] + // Ported to Windows alongside Day, which produces its content: proactive.ts builds + // notifications from getDayPlan / getEventPrep, so shipping this without Day would + // have delivered an empty surface. Delivery is Electron's Notification (guarded by + // isSupported()), and core already sets the AppUserModelID that Windows requires + // for a toast to appear at all. notify.ts / proactive.ts carry no platform code. + platforms: ['darwin', 'win32'] }, { route: 'voice', @@ -262,3 +273,23 @@ export function proFeatureComingSoon( } return !featureSupportsPlatform(feature, platform) } + +/** + * Which view the app should OPEN on. Free users land on Models (they need a model + * before anything else works); Pro users land on Day — but only where Day is + * actually available. + * + * This lives here, beside the seam, because it is a per-feature platform decision and + * `platforms` is the single source of truth for those. It previously sat in App.tsx as + * `isPro && isMac() ? 'day' : 'models'`, which was right only by accident: it agreed + * with the catalog while Day was macOS-only, and would have stranded a ported Day on + * Windows — nav and gating would light Day up from `platforms` while the landing + * screen still asked `isMac()`. Route the decision through the seam so porting a + * feature never leaves a second place to update. + * + * Never land on a locked or unavailable tab. + */ +export function landingView(platform: DevicePlatform, isPro: boolean): 'day' | 'models' { + const day = getProFeature('day') + return isPro && day && featureSupportsPlatform(day, platform) ? 'day' : 'models' +} diff --git a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts index bb53790b..e2197667 100644 --- a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts +++ b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts @@ -10,6 +10,7 @@ import { featureSupportsPlatform, proComingSoonHere, proFeatureComingSoon, + landingView, PRO_FEATURES, PRO_PAY_URL, type ProFeature @@ -38,7 +39,7 @@ const winPorted = (route: string): ProFeature => ({ // asserted against the catalog so a flipped `platforms` and this list can't drift. // Module-scoped because both the featureSupportsPlatform and proFeatureComingSoon // describes read it — the gate and the capability check must agree on one list. -const WIN_PORTED = new Set(['vault', 'clipboard', 'replay']) +const WIN_PORTED = new Set(['vault', 'clipboard', 'replay', 'day', 'notifications']) describe('getProFeature', () => { it('returns the matching feature for a known route', () => { @@ -169,6 +170,46 @@ describe('proFeatureComingSoon', () => { }) }) +describe('landingView reads the seam, not isMac (the stranded-Day guard)', () => { + it('sends free users to Models on every platform (they need a model first)', () => { + for (const p of ['darwin', 'win32', 'linux', 'unknown'] as const) { + expect(landingView(p, false), `free on ${p}`).toBe('models') + } + }) + + it('lands a Pro user on Day on macOS', () => { + expect(landingView('darwin', true)).toBe('day') + }) + + // THE regression guard for the rule this replaced. The old landing default was + // `isPro && isMac() ? 'day' : 'models'`, which returns 'models' on win32 no matter + // what the catalog says. With Day ported, nav and gating light it up from + // `platforms` — so an isMac-based landing screen would strand a Pro Windows user on + // Models. This fails the moment anything reintroduces that check. + it('lands a Pro user on Day on Windows now that Day is ported', () => { + expect(landingView('win32', true)).toBe('day') + }) + + // The other half: not "any non-Mac gets Day" either. Day is not ported to linux, so + // a Pro linux user must NOT be dropped onto an unavailable tab. + it('does not land a Pro user on Day where Day is unsupported', () => { + expect(landingView('linux', true)).toBe('models') + expect(landingView('unknown', true)).toBe('models') + }) + + // DRY: assert against the catalog rather than re-hardcoding the platform list, so + // this test and `platforms` can never drift. Porting Day to a new platform updates + // both sides from the one edit. + it('agrees with the day feature’s own platforms list on every platform', () => { + const day = getProFeature('day') + expect(day).toBeDefined() + for (const p of ['darwin', 'win32', 'linux', 'unknown'] as const) { + const expected = featureSupportsPlatform(day!, p) ? 'day' : 'models' + expect(landingView(p, true), `pro landing on ${p}`).toBe(expected) + } + }) +}) + describe('PRO_FEATURES data integrity', () => { it('has a non-empty catalog', () => { expect(PRO_FEATURES.length).toBeGreaterThan(0)