diff --git a/.claude/research/paywall-redesign.md b/.claude/research/paywall-redesign.md
new file mode 100644
index 0000000000..5d63ad6c75
--- /dev/null
+++ b/.claude/research/paywall-redesign.md
@@ -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.
diff --git a/apps/mobile/__tests__/components/calendar-shell.test.tsx b/apps/mobile/__tests__/components/calendar-shell.test.tsx
new file mode 100644
index 0000000000..8cd3dd48da
--- /dev/null
+++ b/apps/mobile/__tests__/components/calendar-shell.test.tsx
@@ -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) =>
+ 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 };
+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(
+ ,
+ ) 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(
+ ,
+ ) as unknown as Tree;
+ });
+
+ const texts = hostTextValues(tree!);
+ expect(texts).toContain("Best streak");
+ expect(texts).toContain("Total logs");
+ expect(texts).toContain("Missed");
+ });
+});
diff --git a/apps/mobile/__tests__/components/calendar-sync-select-all-toggle.test.tsx b/apps/mobile/__tests__/components/calendar-sync-select-all-toggle.test.tsx
new file mode 100644
index 0000000000..d8a3d298b7
--- /dev/null
+++ b/apps/mobile/__tests__/components/calendar-sync-select-all-toggle.test.tsx
@@ -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) =>
+ 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
+}
+
+function renderToggle(allSelected: boolean, onToggle: () => void) {
+ let tree: { root: { findAll: (predicate: (node: TestNode) => boolean) => TestNode[] } }
+ TestRenderer.act(() => {
+ tree = TestRenderer.create(
+ ,
+ )
+ })
+ return tree!
+}
+
+function getButton(tree: ReturnType) {
+ 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)
+ })
+})
diff --git a/apps/mobile/app/(tabs)/calendar.tsx b/apps/mobile/app/(tabs)/calendar.tsx
index 72136e3974..aa1728d7b2 100644
--- a/apps/mobile/app/(tabs)/calendar.tsx
+++ b/apps/mobile/app/(tabs)/calendar.tsx
@@ -17,6 +17,8 @@ import { useRouter } from "expo-router";
import {
addMonths,
subMonths,
+ addYears,
+ subYears,
startOfMonth,
endOfMonth,
startOfWeek,
@@ -41,13 +43,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;
@@ -122,6 +124,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()));
@@ -243,6 +255,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)
@@ -281,24 +317,7 @@ export default function CalendarScreen() {
const listFooter = (
{t("calendar.thisMonth")}
-
-
-
+
@@ -311,9 +330,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}
/>
diff --git a/apps/mobile/app/(tabs)/calendar/_components/calendar-shell.tsx b/apps/mobile/app/(tabs)/calendar/_components/calendar-shell.tsx
index 2053d4a71b..4b2c4efd5c 100644
--- a/apps/mobile/app/(tabs)/calendar/_components/calendar-shell.tsx
+++ b/apps/mobile/app/(tabs)/calendar/_components/calendar-shell.tsx
@@ -1,5 +1,10 @@
import { useMemo, useRef } from "react";
-import { ChevronLeft, ChevronRight } from "lucide-react-native";
+import {
+ ChevronLeft,
+ ChevronRight,
+ ChevronsLeft,
+ ChevronsRight,
+} from "lucide-react-native";
import {
Pressable,
StyleSheet,
@@ -13,9 +18,13 @@ interface CalendarHeaderProps {
monthLabel: string;
previousMonthLabel: string;
nextMonthLabel: string;
+ previousYearLabel: string;
+ nextYearLabel: string;
currentMonthLabel: string;
onPreviousMonth: () => void;
onNextMonth: () => void;
+ onPreviousYear: () => void;
+ onNextYear: () => void;
onCurrentMonth: () => void;
tokens: ReturnType;
}
@@ -38,6 +47,11 @@ function createStyles(tokens: ReturnType) {
paddingTop: 12,
paddingBottom: 4,
},
+ monthNavGroup: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ },
monthNavButton: {
width: 36,
height: 36,
@@ -125,9 +139,13 @@ export function CalendarHeader({
monthLabel,
previousMonthLabel,
nextMonthLabel,
+ previousYearLabel,
+ nextYearLabel,
currentMonthLabel,
onPreviousMonth,
onNextMonth,
+ onPreviousYear,
+ onNextYear,
onCurrentMonth,
tokens,
}: CalendarHeaderProps) {
@@ -137,18 +155,32 @@ export function CalendarHeader({
return (
- [
- styles.monthNavButton,
- pressed && styles.monthNavButtonPressed,
- ]}
- >
-
-
+
+ [
+ styles.monthNavButton,
+ pressed && styles.monthNavButtonPressed,
+ ]}
+ >
+
+
+ [
+ styles.monthNavButton,
+ pressed && styles.monthNavButtonPressed,
+ ]}
+ >
+
+
+
- [
- styles.monthNavButton,
- pressed && styles.monthNavButtonPressed,
- ]}
- >
-
-
+
+ [
+ styles.monthNavButton,
+ pressed && styles.monthNavButtonPressed,
+ ]}
+ >
+
+
+ [
+ styles.monthNavButton,
+ pressed && styles.monthNavButtonPressed,
+ ]}
+ >
+
+
+
);
}
diff --git a/apps/mobile/app/(tabs)/calendar/_components/calendar-stats.tsx b/apps/mobile/app/(tabs)/calendar/_components/calendar-stats.tsx
new file mode 100644
index 0000000000..f49b475599
--- /dev/null
+++ b/apps/mobile/app/(tabs)/calendar/_components/calendar-stats.tsx
@@ -0,0 +1,41 @@
+import { StyleSheet, View } from "react-native";
+import { StatTile } from "@/components/ui/stat-tile";
+
+export interface CalendarStat {
+ key: string;
+ emoji: string;
+ value: string | number;
+ label: string;
+}
+
+interface CalendarStatsProps {
+ stats: readonly CalendarStat[];
+}
+
+/** At-a-glance, data-driven month stat section: each entry renders as a kit StatTile
+ * in a wrapping 3-up grid so new stats drop in as array entries without a layout rewrite. */
+export function CalendarStats({ stats }: Readonly) {
+ return (
+
+ {stats.map((stat) => (
+
+
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 10,
+ paddingHorizontal: 20,
+ },
+ cell: {
+ flexGrow: 1,
+ flexBasis: "30%",
+ minWidth: 96,
+ },
+});
diff --git a/apps/mobile/app/calendar-sync-select-all-toggle.tsx b/apps/mobile/app/calendar-sync-select-all-toggle.tsx
new file mode 100644
index 0000000000..67204b231a
--- /dev/null
+++ b/apps/mobile/app/calendar-sync-select-all-toggle.tsx
@@ -0,0 +1,43 @@
+import { Pressable, type StyleProp, type ViewStyle } from 'react-native'
+import { CheckCheck, SquareX } from 'lucide-react-native'
+import { createTokensV2 } from '@/lib/theme'
+import { createStyles } from './calendar-sync-styles'
+
+interface SelectAllToggleProps {
+ allSelected: boolean
+ onToggle: () => void
+ selectAllLabel: string
+ deselectAllLabel: string
+ tokens: ReturnType
+ tintStyle: StyleProp
+}
+
+/** Icon Pressable that selects or deselects every calendar event; its label is the accessible name. */
+export function SelectAllToggle({
+ allSelected,
+ onToggle,
+ selectAllLabel,
+ deselectAllLabel,
+ tokens,
+ tintStyle,
+}: Readonly) {
+ const styles = createStyles()
+ return (
+ [
+ styles.quietActionIcon,
+ tintStyle,
+ pressed && styles.quietActionDim,
+ ]}
+ >
+ {allSelected ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/mobile/app/calendar-sync-styles.ts b/apps/mobile/app/calendar-sync-styles.ts
index 8a55295c3f..de286d0ec9 100644
--- a/apps/mobile/app/calendar-sync-styles.ts
+++ b/apps/mobile/app/calendar-sync-styles.ts
@@ -115,6 +115,14 @@ export function createStyles() {
fontFamily: 'Rubik_500Medium',
fontSize: 13,
},
+ quietActionIcon: {
+ width: 36,
+ height: 36,
+ borderRadius: 999,
+ borderWidth: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
actionPad: {
paddingHorizontal: 20,
paddingVertical: 18,
diff --git a/apps/mobile/app/calendar-sync.tsx b/apps/mobile/app/calendar-sync.tsx
index c41d85862f..04a7c46068 100644
--- a/apps/mobile/app/calendar-sync.tsx
+++ b/apps/mobile/app/calendar-sync.tsx
@@ -42,6 +42,7 @@ import { PillButton } from '@/components/ui/pill-button'
import { CalendarAutoSyncSection } from './calendar-sync-auto-section'
import { CalendarPickerSection } from './calendar-picker-section'
import { CalendarSyncEventRow } from './calendar-sync-event-row'
+import { SelectAllToggle } from './calendar-sync-select-all-toggle'
import { createStyles } from './calendar-sync-styles'
const EVENTS_PAGE_SIZE = 20
@@ -518,21 +519,14 @@ export default function CalendarSyncScreen() {
<>
[
- styles.quietAction,
- chipTint,
- pressed && styles.quietActionDim,
- ]}
- >
-
- {allSelected
- ? t('calendar.deselectAll')
- : t('calendar.selectAll')}
-
-
+
}
>
{plural(
diff --git a/apps/mobile/test-mocks/lucide-react-native.ts b/apps/mobile/test-mocks/lucide-react-native.ts
index caed92ec50..67ed82f26a 100644
--- a/apps/mobile/test-mocks/lucide-react-native.ts
+++ b/apps/mobile/test-mocks/lucide-react-native.ts
@@ -15,12 +15,15 @@ export const Bell = createIcon('Bell')
export const CalendarCheck = createIcon('CalendarCheck')
export const CalendarClock = createIcon('CalendarClock')
export const Check = createIcon('Check')
+export const CheckCheck = createIcon('CheckCheck')
export const CheckCircle2 = createIcon('CheckCircle2')
export const CalendarDays = createIcon('CalendarDays')
export const ChevronDown = createIcon('ChevronDown')
export const ChevronLeft = createIcon('ChevronLeft')
export const ChevronRight = createIcon('ChevronRight')
export const ChevronsDownUp = createIcon('ChevronsDownUp')
+export const ChevronsLeft = createIcon('ChevronsLeft')
+export const ChevronsRight = createIcon('ChevronsRight')
export const ChevronsUpDown = createIcon('ChevronsUpDown')
export const ClipboardCheck = createIcon('ClipboardCheck')
export const ClipboardList = createIcon('ClipboardList')
@@ -50,6 +53,7 @@ export const ShieldAlert = createIcon('ShieldAlert')
export const ShieldCheck = createIcon('ShieldCheck')
export const Shuffle = createIcon('Shuffle')
export const Sparkles = createIcon('Sparkles')
+export const SquareX = createIcon('SquareX')
export const Tag = createIcon('Tag')
export const Trash2 = createIcon('Trash2')
export const TrendingDown = createIcon('TrendingDown')
diff --git a/apps/web/__tests__/components/calendar/calendar-grid.test.tsx b/apps/web/__tests__/components/calendar/calendar-grid.test.tsx
index 0272d5ca7d..e260838ba0 100644
--- a/apps/web/__tests__/components/calendar/calendar-grid.test.tsx
+++ b/apps/web/__tests__/components/calendar/calendar-grid.test.tsx
@@ -146,4 +146,18 @@ describe('CalendarGrid', () => {
const disabledCells = document.querySelectorAll('[aria-disabled="true"]')
expect(disabledCells.length).toBeGreaterThan(0)
})
+
+ it('marks in-range days when range endpoints are provided', () => {
+ const { container } = render(
+ ,
+ )
+ const inRange = container.querySelectorAll('[data-in-range="true"]')
+ expect(inRange.length).toBe(3)
+ })
})
diff --git a/apps/web/__tests__/components/calendar/calendar-shell.test.tsx b/apps/web/__tests__/components/calendar/calendar-shell.test.tsx
index 6c0763837d..74a2ce8f51 100644
--- a/apps/web/__tests__/components/calendar/calendar-shell.test.tsx
+++ b/apps/web/__tests__/components/calendar/calendar-shell.test.tsx
@@ -7,13 +7,16 @@ vi.mock('next-intl', () => ({
import {
CalendarHeader,
+ CalendarWeekNav,
CalendarLegend,
} from '@/app/(app)/calendar/_components/calendar-shell'
describe('Calendar shell helpers', () => {
- it('renders the calendar header actions', () => {
+ it('renders the calendar header actions including year jumps', () => {
const onPreviousMonth = vi.fn()
const onNextMonth = vi.fn()
+ const onPreviousYear = vi.fn()
+ const onNextYear = vi.fn()
const onCurrentMonth = vi.fn()
render(
@@ -21,9 +24,13 @@ describe('Calendar shell helpers', () => {
monthLabel="April 2026"
previousMonthLabel="common.previousMonth"
nextMonthLabel="common.nextMonth"
+ previousYearLabel="common.previousYear"
+ nextYearLabel="common.nextYear"
currentMonthLabel="calendar.goToCurrentMonth"
onPreviousMonth={onPreviousMonth}
onNextMonth={onNextMonth}
+ onPreviousYear={onPreviousYear}
+ onNextYear={onNextYear}
onCurrentMonth={onCurrentMonth}
/>,
)
@@ -32,13 +39,45 @@ describe('Calendar shell helpers', () => {
fireEvent.click(screen.getByLabelText('common.previousMonth'))
fireEvent.click(screen.getByLabelText('common.nextMonth'))
+ fireEvent.click(screen.getByLabelText('common.previousYear'))
+ fireEvent.click(screen.getByLabelText('common.nextYear'))
fireEvent.click(screen.getByLabelText('calendar.goToCurrentMonth'))
expect(onPreviousMonth).toHaveBeenCalledTimes(1)
expect(onNextMonth).toHaveBeenCalledTimes(1)
+ expect(onPreviousYear).toHaveBeenCalledTimes(1)
+ expect(onNextYear).toHaveBeenCalledTimes(1)
expect(onCurrentMonth).toHaveBeenCalledTimes(1)
})
+ it('renders the week nav and fires week handlers', () => {
+ const onPreviousWeek = vi.fn()
+ const onNextWeek = vi.fn()
+ const onCurrentWeek = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Jun 16 - 22')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('common.previousWeek'))
+ fireEvent.click(screen.getByLabelText('common.nextWeek'))
+ fireEvent.click(screen.getByLabelText('calendar.goToCurrentWeek'))
+
+ expect(onPreviousWeek).toHaveBeenCalledTimes(1)
+ expect(onNextWeek).toHaveBeenCalledTimes(1)
+ expect(onCurrentWeek).toHaveBeenCalledTimes(1)
+ })
+
it('renders the calendar legend labels inline', () => {
render(
{
+ it('renders one stat tile per stat entry', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Best streak')).toBeInTheDocument()
+ expect(screen.getByText('5')).toBeInTheDocument()
+ expect(screen.getByText('Total logs')).toBeInTheDocument()
+ expect(screen.getByText('12')).toBeInTheDocument()
+ expect(screen.getByText('Missed')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/__tests__/components/calendar/calendar-time-grid.test.tsx b/apps/web/__tests__/components/calendar/calendar-time-grid.test.tsx
new file mode 100644
index 0000000000..80e4a01483
--- /dev/null
+++ b/apps/web/__tests__/components/calendar/calendar-time-grid.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { enUS } from 'date-fns/locale'
+
+import { CalendarTimeGrid, type TimeGridColumn } from '@/components/calendar/calendar-time-grid'
+import type { CalendarDayEntry } from '@orbit/shared/types/calendar'
+
+function makeEntry(overrides: Partial = {}): CalendarDayEntry {
+ return {
+ habitId: '1',
+ title: 'Meditate',
+ status: 'upcoming',
+ isBadHabit: false,
+ dueTime: null,
+ isOneTime: false,
+ ...overrides,
+ }
+}
+
+const displayTime = (time: string) => time
+
+function column(year: number, month: number, day: number): TimeGridColumn {
+ const date = new Date(year, month, day)
+ return {
+ date,
+ dateStr: `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
+ isToday: false,
+ }
+}
+
+function renderGrid(columns: TimeGridColumn[], dayMap: Map, onSelectDay = vi.fn()) {
+ return render(
+ ,
+ )
+}
+
+describe('CalendarTimeGrid', () => {
+ it('places a timed habit in its hour slot in the correct column', () => {
+ const col = column(2025, 5, 16)
+ const dayMap = new Map([
+ [col.dateStr, [makeEntry({ habitId: 'a', title: 'Standup', dueTime: '08:00' })]],
+ ])
+ renderGrid([col], dayMap)
+
+ const block = screen.getByTestId('time-grid-event')
+ expect(block).toHaveAttribute('data-hour', '8')
+ expect(block).toHaveTextContent('Standup')
+ })
+
+ it('places an untimed habit in the all-day row, not the time body', () => {
+ const col = column(2025, 5, 16)
+ const dayMap = new Map([
+ [col.dateStr, [makeEntry({ habitId: 'b', title: 'Read', dueTime: null })]],
+ ])
+ renderGrid([col], dayMap)
+
+ expect(screen.queryByTestId('time-grid-event')).toBeNull()
+ const allDayEvent = screen.getByTestId('time-grid-all-day-event')
+ expect(allDayEvent).toHaveTextContent('Read')
+ })
+
+ it('renders one column per day in the selected range', () => {
+ const columns = [16, 17, 18, 19].map((d) => column(2025, 5, d))
+ renderGrid(columns, new Map())
+
+ expect(screen.getByTestId('calendar-time-grid')).toHaveAttribute('data-columns', '4')
+ expect(screen.getAllByTestId('time-grid-col-header')).toHaveLength(4)
+ })
+
+ it('opens the clicked day from a column header', () => {
+ const onSelectDay = vi.fn()
+ const col = column(2025, 5, 16)
+ renderGrid([col], new Map(), onSelectDay)
+
+ fireEvent.click(screen.getByTestId('time-grid-col-header'))
+ expect(onSelectDay).toHaveBeenCalledWith('2025-06-16')
+ })
+})
diff --git a/apps/web/__tests__/components/calendar/select-all-toggle.test.tsx b/apps/web/__tests__/components/calendar/select-all-toggle.test.tsx
new file mode 100644
index 0000000000..fef6c1b4d0
--- /dev/null
+++ b/apps/web/__tests__/components/calendar/select-all-toggle.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+
+import { SelectAllToggle } from '@/app/(app)/calendar-sync/_components/select-all-toggle'
+
+const selectAllLabel = 'Select all'
+const deselectAllLabel = 'Deselect all'
+
+describe('SelectAllToggle', () => {
+ it('labels the action as select-all when nothing is selected', () => {
+ render(
+ ,
+ )
+ const button = screen.getByRole('button')
+ expect(button.getAttribute('aria-label')).toBe(selectAllLabel)
+ expect(button.getAttribute('title')).toBe(selectAllLabel)
+ expect(button.getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('labels the action as deselect-all when everything is selected', () => {
+ render(
+ ,
+ )
+ const button = screen.getByRole('button')
+ expect(button.getAttribute('aria-label')).toBe(deselectAllLabel)
+ expect(button.getAttribute('title')).toBe(deselectAllLabel)
+ expect(button.getAttribute('aria-pressed')).toBe('true')
+ })
+
+ it('calls onToggle when clicked', () => {
+ const onToggle = vi.fn()
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByRole('button'))
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/web/__tests__/pages/calendar-sync.test.tsx b/apps/web/__tests__/pages/calendar-sync.test.tsx
index ab31009ee2..208945144b 100644
--- a/apps/web/__tests__/pages/calendar-sync.test.tsx
+++ b/apps/web/__tests__/pages/calendar-sync.test.tsx
@@ -386,9 +386,9 @@ describe('CalendarSyncPage', () => {
render()
await waitFor(() => {
- expect(screen.getByText('calendar.deselectAll')).toBeInTheDocument()
+ expect(screen.getByLabelText('calendar.deselectAll')).toBeInTheDocument()
})
- expect(screen.getByText('calendar.deselectAll').closest('button')).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByLabelText('calendar.deselectAll')).toHaveAttribute('aria-pressed', 'true')
})
it('all events are selected by default', async () => {
@@ -407,7 +407,7 @@ describe('CalendarSyncPage', () => {
expect(screen.getByText('Event 1')).toBeInTheDocument()
})
- expect(screen.getByText('calendar.deselectAll')).toBeInTheDocument()
+ expect(screen.getByLabelText('calendar.deselectAll')).toBeInTheDocument()
expect(document.body.textContent).toContain('calendar.importButton')
})
diff --git a/apps/web/app/(app)/calendar-sync/_components/select-all-toggle.tsx b/apps/web/app/(app)/calendar-sync/_components/select-all-toggle.tsx
new file mode 100644
index 0000000000..eed18f3fe3
--- /dev/null
+++ b/apps/web/app/(app)/calendar-sync/_components/select-all-toggle.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { CheckCheck, SquareX } from 'lucide-react'
+
+interface SelectAllToggleProps {
+ allSelected: boolean
+ onToggle: () => void
+ selectAllLabel: string
+ deselectAllLabel: string
+}
+
+/** Icon button that selects or deselects every calendar event; its label is both the accessible name and the tooltip. */
+export function SelectAllToggle({
+ allSelected,
+ onToggle,
+ selectAllLabel,
+ deselectAllLabel,
+}: Readonly) {
+ const label = allSelected ? deselectAllLabel : selectAllLabel
+ return (
+
+ )
+}
diff --git a/apps/web/app/(app)/calendar-sync/page.tsx b/apps/web/app/(app)/calendar-sync/page.tsx
index b548186c2d..a940b9bd20 100644
--- a/apps/web/app/(app)/calendar-sync/page.tsx
+++ b/apps/web/app/(app)/calendar-sync/page.tsx
@@ -26,6 +26,7 @@ import { useCalendarEvents } from '@/hooks/use-calendar-events'
import { CalendarPickerSection } from './_components/calendar-picker-section'
import { AutoSyncSettingsCard } from './_components/auto-sync-settings-card'
import { CalendarSyncEventRow } from './_components/calendar-sync-event-row'
+import { SelectAllToggle } from './_components/select-all-toggle'
import { connectGoogle } from './_components/connect-google'
import type { CalendarSyncEvent, CalendarSyncSuggestion } from '@orbit/shared'
import {
@@ -324,14 +325,12 @@ export default function CalendarSyncPage() {
>
{plural(t('calendar.eventsFound', { count: events.length }), events.length)}
-
+
diff --git a/apps/web/app/(app)/calendar/_components/calendar-shell.tsx b/apps/web/app/(app)/calendar/_components/calendar-shell.tsx
index c1d5442741..c903ddf884 100644
--- a/apps/web/app/(app)/calendar/_components/calendar-shell.tsx
+++ b/apps/web/app/(app)/calendar/_components/calendar-shell.tsx
@@ -1,26 +1,35 @@
'use client'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
interface CalendarHeaderProps {
monthLabel: string
previousMonthLabel: string
nextMonthLabel: string
+ previousYearLabel: string
+ nextYearLabel: string
currentMonthLabel: string
onPreviousMonth: () => void
onNextMonth: () => void
+ onPreviousYear: () => void
+ onNextYear: () => void
onCurrentMonth: () => void
}
/** Agenda header mirroring the Today date-nav: a centered, tappable month label
- * (tap returns to the current month) flanked by prev/next icon buttons. */
+ * (tap returns to the current month) flanked by month chevrons and outer
+ * double-chevron year-jump controls. */
export function CalendarHeader({
monthLabel,
previousMonthLabel,
nextMonthLabel,
+ previousYearLabel,
+ nextYearLabel,
currentMonthLabel,
onPreviousMonth,
onNextMonth,
+ onPreviousYear,
+ onNextYear,
onCurrentMonth,
}: Readonly
) {
return (
@@ -30,10 +39,96 @@ export function CalendarHeader({
className="flex items-center justify-between w-full"
style={{ padding: '0 4px' }}
>
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface CalendarWeekNavProps {
+ weekLabel: string
+ previousWeekLabel: string
+ nextWeekLabel: string
+ currentWeekLabel: string
+ onPreviousWeek: () => void
+ onNextWeek: () => void
+ onCurrentWeek: () => void
+}
+
+/** Week-granularity nav for the week time-grid: a centered, tappable week-range
+ * label (tap returns to the current week) flanked by prev/next week chevrons. */
+export function CalendarWeekNav({
+ weekLabel,
+ previousWeekLabel,
+ nextWeekLabel,
+ currentWeekLabel,
+ onPreviousWeek,
+ onNextWeek,
+ onCurrentWeek,
+}: Readonly) {
+ return (
+
+
+