Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
46 changes: 46 additions & 0 deletions .claude/research/paywall-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Upgrade/Paywall Redesign — design brief (PR 6, task #7)

Research-backed. Anchor: navy-violet orbital per DESIGN.md (semantic tokens only, ONE filled CTA, no countdowns/urgency).

## Core insight (fixes the repetition + the yearly-only-feature problem)
- **Chooser = 3 plan cards** (Free · **Pro-Yearly hero, center** · Pro-Monthly).
- **Matrix = only 2 columns (Free vs Pro), placed BELOW the cards.** Monthly & yearly are the same Pro product minus one perk → the matrix never forks into near-identical columns (that *is* the current repetition).
- Cards demote to **3–4 differentiators + price + CTA**; yearly card says **"Everything in Pro Monthly, plus AI Retrospective."** Matrix is the single exhaustive list.

## Data-model change (also fixes the retrospective bug)
`packages/shared/src/utils/upgrade.ts` — the matrix cell currently has `freeEnabled/proEnabled` booleans and retrospective is hacked `proEnabled:false`. Add a **third Pro state**: `pro: 'yearly' | true | false`. Render `'yearly'` as `✓` + a small inline **`Yearly`** pill. Shrink `upgrade.plans.proFeatures` to ~3–4 marquee keys; `trial.expired` points at the same source (no third copy).

## Post-#186 entitlement matrix (reflect the free-tier rebalance — the screen is built on #186)
| Feature | Free | Pro |
|---|---|---|
| Habits | 10 | Unlimited |
| Sub-habits, Goals | ✗ | ✓ |
| AI messages | 20/mo | 500/mo |
| AI memory, Daily summary, Slip alerts, API keys | ✗ | ✓ |
| Current/longest streak, XP/Level, Streak-freeze | **✓ (free, per #186)** | ✓ |
| Achievements & XP system | ✗ | ✓ |
| **AI Retrospective** | ✗ | **✓ `Yearly`** |
| Themes/colors, Calendar, Ad-free | 1 theme | all |
Mark availability **positively** (checks for what you get); **value-text for quantities** ("10"/"Unlimited"/"500/mo"); **"Not included"** text not a bare dash (a11y); grouped categories (Habits / AI / Insights / Personalization) + sticky header row.

## Web layout (~640px cap)
Gradient header (existing GradientTop) → ONE promise line (said once) → 3 cards: Free ($0, muted, outline "Stay free") · **Yearly center, raised+glow, violet badge "Save X%", filled glow pill CTA, per-month price + "billed $XX/yr · Save $Z a year"** · Monthly (right, outline, higher per-month = price anchor). Per-month price on ALL three (ascending $0→$X→$Y). Then "Compare every feature" → the 2-col matrix. Price in Inter (num), billing line Roboto (meta), else Rubik.

## Trial framing (convert-not-start — trial is a backend signup grant; stores charge immediately)
The paywall is a CONVERT surface, not trial-start. State-aware CTA:
- Trial active → "Pro Trial · N days left", loss-aversion ("Keep your Pro features"), CTA **"Subscribe to keep Pro"**, "Cancel anytime."
- Expired/free → "Your Pro trial ended…", CTA **"Upgrade to Pro."**
- The "no card was ever taken · you dropped to Free automatically" line is a genuine trust differentiator — state it plainly.
- Reassurance microcopy directly UNDER the CTA (not footer). First-person CTA ("…my…").
- Discount: per-month-billed-annually as the big number + **"Save $Z a year"** (dollar > %); keep the "Save %" badge; use **"2 months free"** if the real discount is ~15–20%. Real discount only (no phantom monthly).

## Mobile (~412px) — stack, don't shrink
Header → **stacked full-width selectable cards** (Yearly hero pre-selected on top + "Save X%", Monthly below, Free as a quiet "Stay on Free" link) → 3–5 marquee differentiators (icon list, not a checkmark wall) → **"Compare all features" accordion** hiding the full matrix → **single sticky bottom glow-pill CTA** echoing the selected plan's price+terms → "No card · Cancel anytime" under it.

## Forks decided (per "no more questions" — confirm later if wanted)
1. Column order: **center-stage Yearly** (Free·Yearly·Monthly).
2. Discount copy: **per-month + "Save $Z/year"** (+ "2 months free" if ~15–20%).
3. Trial surface: **convert-not-start**, in-app CTA "Subscribe to keep Pro".

## Files (from the inventory)
shared: `utils/upgrade.ts` (the cell-state model + shrink proFeatures), i18n both locales. web: `app/(app)/upgrade/page.tsx`, `components/upgrade/{pricing-section,plan-selection,plan-comparison-cards}.tsx`. mobile: `app/upgrade.tsx` + `components/upgrade/*`. Verify whether phase-4 (#186) touched `upgrade.ts` before branching to avoid a merge conflict; set the matrix to the post-#186 split regardless.
116 changes: 116 additions & 0 deletions apps/mobile/__tests__/components/calendar-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";

const TestRenderer = require("react-test-renderer");

vi.mock("@/hooks/use-tour-target", () => ({
useTourTarget: () => {},
}));

vi.mock("lucide-react-native", () => {
const makeIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement(name, props);
return {
ChevronLeft: makeIcon("ChevronLeft"),
ChevronRight: makeIcon("ChevronRight"),
ChevronsLeft: makeIcon("ChevronsLeft"),
ChevronsRight: makeIcon("ChevronsRight"),
};
});

vi.mock("@/components/ui/stat-tile", () => ({
StatTile: ({ value, label }: { value: string | number; label: string }) =>
React.createElement(
"View",
null,
React.createElement("Text", null, value),
React.createElement("Text", null, label),
),
}));

import { createTokensV2 } from "@/lib/theme";
import { CalendarHeader } from "@/app/(tabs)/calendar/_components/calendar-shell";
import {
CalendarStats,
type CalendarStat,
} from "@/app/(tabs)/calendar/_components/calendar-stats";

type TestNode = { type: unknown; props: Record<string, any> };
type Tree = {
root: { findAll: (predicate: (node: TestNode) => boolean) => TestNode[] };
};

function hostTextValues(tree: Tree): unknown[] {
return tree.root
.findAll((node) => node.type === "Text")
.map((node) => node.props.children);
}

function pressByAccessibilityLabel(tree: Tree, label: string) {
const matches = tree.root.findAll(
(node) =>
typeof node.type === "string" && node.props.accessibilityLabel === label,
);
expect(matches).toHaveLength(1);
TestRenderer.act(() => {
matches[0]!.props.onPress();
});
}

describe("CalendarHeader year navigation (mobile)", () => {
it("fires the year handlers and renders the month label", () => {
const onPreviousYear = vi.fn();
const onNextYear = vi.fn();
const tokens = createTokensV2("purple", "dark");

let tree: Tree;
TestRenderer.act(() => {
tree = TestRenderer.create(
<CalendarHeader
monthLabel="April 2026"
previousMonthLabel="Previous month"
nextMonthLabel="Next month"
previousYearLabel="Previous year"
nextYearLabel="Next year"
currentMonthLabel="Go to current month"
onPreviousMonth={vi.fn()}
onNextMonth={vi.fn()}
onPreviousYear={onPreviousYear}
onNextYear={onNextYear}
onCurrentMonth={vi.fn()}
tokens={tokens}
/>,
) as unknown as Tree;
});

expect(hostTextValues(tree!)).toContain("April 2026");

pressByAccessibilityLabel(tree!, "Previous year");
pressByAccessibilityLabel(tree!, "Next year");

expect(onPreviousYear).toHaveBeenCalledTimes(1);
expect(onNextYear).toHaveBeenCalledTimes(1);
});
});

describe("CalendarStats (mobile)", () => {
it("renders a tile for each stat, surfacing its label", () => {
const stats: readonly CalendarStat[] = [
{ key: "bestStreak", emoji: "🔥", value: 5, label: "Best streak" },
{ key: "totalLogs", emoji: "✅", value: 42, label: "Total logs" },
{ key: "missed", emoji: "⚠️", value: 3, label: "Missed" },
];

let tree: Tree;
TestRenderer.act(() => {
tree = TestRenderer.create(
<CalendarStats stats={stats} />,
) as unknown as Tree;
});

const texts = hostTextValues(tree!);
expect(texts).toContain("Best streak");
expect(texts).toContain("Total logs");
expect(texts).toContain("Missed");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'

vi.mock('lucide-react-native', () => {
const ReactModule = require('react')
const createIcon = (name: string) => (props: Record<string, unknown>) =>
ReactModule.createElement(name, props)
return {
CheckCheck: createIcon('CheckCheck'),
SquareX: createIcon('SquareX'),
}
})

import { createTokensV2 } from '@/lib/theme'
import { SelectAllToggle } from '@/app/calendar-sync-select-all-toggle'

const TestRenderer = require('react-test-renderer')

const tokens = createTokensV2('purple', 'dark')
const selectAllLabel = 'Select all'
const deselectAllLabel = 'Deselect all'

interface TestNode {
type: unknown
props: Record<string, unknown>
}

function renderToggle(allSelected: boolean, onToggle: () => void) {
let tree: { root: { findAll: (predicate: (node: TestNode) => boolean) => TestNode[] } }
TestRenderer.act(() => {
tree = TestRenderer.create(
<SelectAllToggle
allSelected={allSelected}
onToggle={onToggle}
selectAllLabel={selectAllLabel}
deselectAllLabel={deselectAllLabel}
tokens={tokens}
tintStyle={undefined}
/>,
)
})
return tree!
}

function getButton(tree: ReturnType<typeof renderToggle>) {
const nodes = tree.root.findAll(
(node) => typeof node.type === 'string' && node.props.accessibilityRole === 'button',
)
return nodes[0]!
}

describe('mobile SelectAllToggle', () => {
it('exposes the select-all label when nothing is selected', () => {
const tree = renderToggle(false, vi.fn())
expect(getButton(tree).props.accessibilityLabel).toBe(selectAllLabel)
})

it('exposes the deselect-all label when everything is selected', () => {
const tree = renderToggle(true, vi.fn())
expect(getButton(tree).props.accessibilityLabel).toBe(deselectAllLabel)
})

it('calls onToggle when pressed', () => {
const onToggle = vi.fn()
const tree = renderToggle(false, onToggle)
TestRenderer.act(() => {
;(getButton(tree).props.onPress as () => void)()
})
expect(onToggle).toHaveBeenCalledTimes(1)
})
})
61 changes: 42 additions & 19 deletions apps/mobile/app/(tabs)/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useRouter } from "expo-router";
import {
addMonths,
subMonths,
addYears,
subYears,
startOfMonth,
endOfMonth,
startOfWeek,
Expand All @@ -40,13 +42,13 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { BottomSheetModal } from "@/components/bottom-sheet-modal";
import { GradientTop } from "@/components/ui/gradient-top";
import { SectionLabel } from "@/components/ui/section-label";
import { SettingsRow } from "@/components/ui/settings-row";
import {
CalendarHeader,
CalendarLegend,
} from "./calendar/_components/calendar-shell";
import { CalendarGrid } from "./calendar/_components/calendar-grid";
import { CalendarDayDetail } from "./calendar/_components/calendar-day-detail";
import { CalendarStats } from "./calendar/_components/calendar-stats";

interface GridDay {
date: Date;
Expand Down Expand Up @@ -120,6 +122,16 @@ export default function CalendarScreen() {
setCurrentMonth((m) => addMonths(m, 1));
}, []);

const prevYear = useCallback(() => {
setMonthSlide("left");
setCurrentMonth((m) => subYears(m, 1));
}, []);

const nextYear = useCallback(() => {
setMonthSlide("right");
setCurrentMonth((m) => addYears(m, 1));
}, []);

const goToCurrentMonth = useCallback(() => {
setMonthSlide(null);
setCurrentMonth(startOfMonth(new Date()));
Expand Down Expand Up @@ -241,6 +253,30 @@ export default function CalendarScreen() {
return { totalLogs, missed, bestStreak };
}, [gridDays]);

const monthStatTiles = useMemo(
() => [
{
key: "bestStreak",
emoji: "🔥",
value: monthStats.bestStreak,
label: t("calendar.bestStreak"),
},
{
key: "totalLogs",
emoji: "✅",
value: monthStats.totalLogs,
label: t("calendar.totalLogs"),
},
{
key: "missed",
emoji: "⚠️",
value: monthStats.missed,
label: t("calendar.missedCount"),
},
],
[monthStats, t],
);

const monthEntering =
monthSlide === "right"
? FadeInRight.duration(220).reduceMotion(ReduceMotion.System)
Expand Down Expand Up @@ -279,24 +315,7 @@ export default function CalendarScreen() {
const listFooter = (
<View style={styles.listFooter}>
<SectionLabel>{t("calendar.thisMonth")}</SectionLabel>
<SettingsRow
label={t("calendar.bestStreak")}
value={String(monthStats.bestStreak)}
accessory="none"
mono
/>
<SettingsRow
label={t("calendar.totalLogs")}
value={String(monthStats.totalLogs)}
accessory="none"
mono
/>
<SettingsRow
label={t("calendar.missedCount")}
value={String(monthStats.missed)}
accessory="none"
mono
/>
<CalendarStats stats={monthStatTiles} />

<View style={{ height: 24 }} />
</View>
Expand All @@ -309,9 +328,13 @@ export default function CalendarScreen() {
monthLabel={monthLabel}
previousMonthLabel={t("common.previousMonth")}
nextMonthLabel={t("common.nextMonth")}
previousYearLabel={t("common.previousYear")}
nextYearLabel={t("common.nextYear")}
currentMonthLabel={t("calendar.goToCurrentMonth")}
onPreviousMonth={prevMonth}
onNextMonth={nextMonth}
onPreviousYear={prevYear}
onNextYear={nextYear}
onCurrentMonth={goToCurrentMonth}
tokens={tokens}
/>
Expand Down
Loading