From fec2f0a19feb61ccbecc92133ca602f89abfe000 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 27 Jun 2026 06:14:32 -0300 Subject: [PATCH] feat(calendar): year arrows, week/N-day grids, stats redesign, sync polish (#8, #13) Year-skip arrows on month nav (both platforms). Month-stats redesigned as a data-driven extensible StatTile grid. Web: view switcher (Month/Week/Range) + Google-Calendar-style time-grid (hourly rows, day columns, timed-habit blocks, all-day band, now-line) for week + custom contiguous-range; mobile keeps month. Sync screen: Sincronizar agora->Sincronizar, deselect-all text->icon button. web+mobile parity for shared parts; grids web-only by design; both locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/research/paywall-redesign.md | 46 ++ .../components/calendar-shell.test.tsx | 116 ++++++ .../calendar-sync-select-all-toggle.test.tsx | 71 ++++ apps/mobile/app/(tabs)/calendar.tsx | 61 ++- .../calendar/_components/calendar-shell.tsx | 96 +++-- .../calendar/_components/calendar-stats.tsx | 41 ++ .../app/calendar-sync-select-all-toggle.tsx | 43 ++ apps/mobile/app/calendar-sync-styles.ts | 8 + apps/mobile/app/calendar-sync.tsx | 24 +- apps/mobile/test-mocks/lucide-react-native.ts | 4 + .../calendar/calendar-grid.test.tsx | 14 + .../calendar/calendar-shell.test.tsx | 41 +- .../calendar/calendar-stats.test.tsx | 25 ++ .../calendar/calendar-time-grid.test.tsx | 86 ++++ .../calendar/select-all-toggle.test.tsx | 53 +++ .../__tests__/pages/calendar-sync.test.tsx | 6 +- .../_components/select-all-toggle.tsx | 37 ++ apps/web/app/(app)/calendar-sync/page.tsx | 15 +- .../calendar/_components/calendar-shell.tsx | 115 ++++- apps/web/app/(app)/calendar/page.tsx | 295 ++++++++++--- .../web/components/calendar/calendar-grid.tsx | 27 +- .../calendar/calendar-range-view.tsx | 76 ++++ .../components/calendar/calendar-stats.tsx | 31 ++ .../calendar/calendar-time-grid.tsx | 393 ++++++++++++++++++ .../calendar/calendar-week-view.tsx | 64 +++ apps/web/hooks/use-calendar-data.ts | 29 +- packages/shared/src/i18n/en.json | 16 +- packages/shared/src/i18n/pt-BR.json | 16 +- 28 files changed, 1687 insertions(+), 162 deletions(-) create mode 100644 .claude/research/paywall-redesign.md create mode 100644 apps/mobile/__tests__/components/calendar-shell.test.tsx create mode 100644 apps/mobile/__tests__/components/calendar-sync-select-all-toggle.test.tsx create mode 100644 apps/mobile/app/(tabs)/calendar/_components/calendar-stats.tsx create mode 100644 apps/mobile/app/calendar-sync-select-all-toggle.tsx create mode 100644 apps/web/__tests__/components/calendar/calendar-stats.test.tsx create mode 100644 apps/web/__tests__/components/calendar/calendar-time-grid.test.tsx create mode 100644 apps/web/__tests__/components/calendar/select-all-toggle.test.tsx create mode 100644 apps/web/app/(app)/calendar-sync/_components/select-all-toggle.tsx create mode 100644 apps/web/components/calendar/calendar-range-view.tsx create mode 100644 apps/web/components/calendar/calendar-stats.tsx create mode 100644 apps/web/components/calendar/calendar-time-grid.tsx create mode 100644 apps/web/components/calendar/calendar-week-view.tsx 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 167628d681..2e903a530f 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, @@ -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; @@ -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())); @@ -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) @@ -279,24 +315,7 @@ export default function CalendarScreen() { const listFooter = ( {t("calendar.thisMonth")} - - - + @@ -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} /> 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 4cf7a6a4a6..b425b8f2d6 100644 --- a/apps/mobile/test-mocks/lucide-react-native.ts +++ b/apps/mobile/test-mocks/lucide-react-native.ts @@ -13,12 +13,15 @@ export const ArrowRight = createIcon('ArrowRight') export const Bell = createIcon('Bell') export const CalendarCheck = createIcon('CalendarCheck') 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') @@ -43,6 +46,7 @@ export const Share2 = createIcon('Share2') export const ShieldAlert = createIcon('ShieldAlert') export const Shuffle = createIcon('Shuffle') export const Sparkles = createIcon('Sparkles') +export const SquareX = createIcon('SquareX') export const Trash2 = createIcon('Trash2') export const User = createIcon('User') export const WifiOff = createIcon('WifiOff') 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 ( +
+
+ + ))} +
+ +
+
+ + {allDayLabel} + +
+ {perColumn.map(({ column, allDay }) => ( +
+ {allDay.map((entry) => ( + + ))} +
+ ))} +
+ +
+
+ + {perColumn.map(({ column, timed }) => ( +
+ {timed.map((block) => ( + + ))} + {column.isToday && ( +
+ + +
+ )} +
+ ))} +
+
+
+ + ) +} diff --git a/apps/web/components/calendar/calendar-week-view.tsx b/apps/web/components/calendar/calendar-week-view.tsx new file mode 100644 index 0000000000..201313d0cc --- /dev/null +++ b/apps/web/components/calendar/calendar-week-view.tsx @@ -0,0 +1,64 @@ +'use client' + +import type { Locale } from 'date-fns' +import type { CalendarDayEntry } from '@orbit/shared/types/calendar' +import { CalendarWeekNav } from '@/app/(app)/calendar/_components/calendar-shell' +import { CalendarTimeGrid, type TimeGridColumn } from './calendar-time-grid' + +interface CalendarWeekViewProps { + columns: ReadonlyArray + dayMap: Map + weekLabel: string + previousWeekLabel: string + nextWeekLabel: string + currentWeekLabel: string + onPreviousWeek: () => void + onNextWeek: () => void + onCurrentWeek: () => void + onSelectDay: (dateStr: string) => void + displayTime: (time: string) => string + dateFnsLocale: Locale + allDayLabel: string + nowLabel: string +} + +/** Week view: a 7-column time grid with week-granularity navigation. */ +export function CalendarWeekView({ + columns, + dayMap, + weekLabel, + previousWeekLabel, + nextWeekLabel, + currentWeekLabel, + onPreviousWeek, + onNextWeek, + onCurrentWeek, + onSelectDay, + displayTime, + dateFnsLocale, + allDayLabel, + nowLabel, +}: Readonly) { + return ( + <> + + + + ) +} diff --git a/apps/web/hooks/use-calendar-data.ts b/apps/web/hooks/use-calendar-data.ts index ede18b126f..932149ce48 100644 --- a/apps/web/hooks/use-calendar-data.ts +++ b/apps/web/hooks/use-calendar-data.ts @@ -10,6 +10,7 @@ import { habitKeys, QUERY_STALE_TIMES } from '@orbit/shared/query' import { API } from '@orbit/shared/api' import { buildCalendarDayMap, formatAPIDate } from '@orbit/shared/utils' import type { CalendarMonthResponse } from '@orbit/shared/types/habit' +import type { CalendarDayEntry } from '@orbit/shared/types/calendar' async function fetchCalendarMonth( monthStart: string, @@ -24,17 +25,15 @@ async function fetchCalendarMonth( return res.json() } -export function useCalendarData(currentMonth: Date) { - const monthStart = formatAPIDate(startOfMonth(currentMonth)) - const monthEnd = formatAPIDate(endOfMonth(currentMonth)) - +function useCalendarRangeQuery(rangeStart: string, rangeEnd: string, enabled = true) { const query = useQuery({ - queryKey: habitKeys.calendar(monthStart, monthEnd), - queryFn: () => fetchCalendarMonth(monthStart, monthEnd), + queryKey: habitKeys.calendar(rangeStart, rangeEnd), + queryFn: () => fetchCalendarMonth(rangeStart, rangeEnd), staleTime: QUERY_STALE_TIMES.habits, + enabled, }) - const dayMap = useMemo(() => { + const dayMap = useMemo>(() => { if (!query.data) return new Map() return buildCalendarDayMap(query.data) }, [query.data]) @@ -47,3 +46,19 @@ export function useCalendarData(currentMonth: Date) { refresh: () => query.refetch(), } } + +export function useCalendarData(currentMonth: Date) { + const monthStart = formatAPIDate(startOfMonth(currentMonth)) + const monthEnd = formatAPIDate(endOfMonth(currentMonth)) + return useCalendarRangeQuery(monthStart, monthEnd) +} + +/** Calendar entries for an arbitrary contiguous date range, powering the + * web-only week and custom-range time-grid views. Disabled in month view. */ +export function useCalendarRange(rangeStart: Date, rangeEnd: Date, enabled = true) { + return useCalendarRangeQuery( + formatAPIDate(rangeStart), + formatAPIDate(rangeEnd), + enabled, + ) +} diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 7f4953a52a..f3792b3bb1 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -141,6 +141,8 @@ "nextMonth": "Next month", "previousYear": "Previous year", "nextYear": "Next year", + "previousWeek": "Previous week", + "nextWeek": "Next week", "continue": "Continue", "unknown": "Unknown", "failed": "Failed", @@ -593,6 +595,18 @@ "noHabitsScheduled": "No habits scheduled for this day.", "goToDay": "View this day", "goToCurrentMonth": "Go to current month", + "goToCurrentWeek": "Go to current week", + "view": { + "switchLabel": "Calendar view", + "month": "Month", + "week": "Week", + "range": "Range" + }, + "timeGrid": { + "allDay": "All-day", + "now": "Now", + "pickRangeHint": "Pick a start and end day above to set your range." + }, "status": { "completed": "Completed", "missed": "Missed", @@ -652,7 +666,7 @@ "lastSyncedHoursAgo": "{n} hour ago | {n} hours ago", "lastSyncedYesterday": "Yesterday", "lastSyncedDaysAgo": "{n} day ago | {n} days ago", - "syncNow": "Sync now", + "syncNow": "Sync", "syncNowRunning": "Syncing...", "connectGoogleFirst": "Connect your Google account first to enable auto-sync.", "reconnectTitle": "Google Calendar disconnected", diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 6d0859f335..7725211053 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -141,6 +141,8 @@ "nextMonth": "Próximo mês", "previousYear": "Ano anterior", "nextYear": "Próximo ano", + "previousWeek": "Semana anterior", + "nextWeek": "Próxima semana", "continue": "Continuar", "unknown": "Desconhecido", "failed": "Falhou", @@ -593,6 +595,18 @@ "noHabitsScheduled": "Nenhum hábito agendado para este dia.", "goToDay": "Ver este dia", "goToCurrentMonth": "Ir para o mês atual", + "goToCurrentWeek": "Ir para a semana atual", + "view": { + "switchLabel": "Visualização do calendário", + "month": "Mês", + "week": "Semana", + "range": "Intervalo" + }, + "timeGrid": { + "allDay": "Dia inteiro", + "now": "Agora", + "pickRangeHint": "Escolha um dia inicial e final acima para definir seu intervalo." + }, "status": { "completed": "Concluído", "missed": "Perdido", @@ -652,7 +666,7 @@ "lastSyncedHoursAgo": "há {n} hora | há {n} horas", "lastSyncedYesterday": "Ontem", "lastSyncedDaysAgo": "há {n} dia | há {n} dias", - "syncNow": "Sincronizar agora", + "syncNow": "Sincronizar", "syncNowRunning": "Sincronizando...", "connectGoogleFirst": "Conecte sua conta Google antes de ativar a sincronização automática.", "reconnectTitle": "Google Calendar desconectado",