diff --git a/src/app/page.tsx b/src/app/page.tsx index 6e4b1fe..05daa91 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,11 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { getYearRangeDates } from "@/lib/date-utils"; +import { resolveRange } from "@/lib/calendar-range"; import { getEventsForRange, getCalendarList, getEventColors } from "@/lib/google-calendar"; import LinearCalendar from "@/components/LinearCalendar"; import styles from "@/components/LinearCalendar.module.css"; import GoogleLoginButton from "@/components/GoogleLoginButton"; import Link from "next/link"; -import { redirect } from "next/navigation"; -import { startOfDay, endOfDay, startOfWeek, endOfWeek, eachDayOfInterval, parse, endOfMonth, isValid } from 'date-fns'; interface PageProps { searchParams: Promise<{ year?: string; calendars?: string; start?: string; end?: string }>; @@ -24,29 +22,12 @@ export default async function Home({ searchParams }: PageProps) { const endParam = awaitedSearchParams?.end; const calendarsParam = awaitedSearchParams?.calendars; - const currentYear = new Date().getFullYear(); - let startDate: Date; - let endDate: Date; - - if (startParam && endParam) { - const s = parse(startParam, 'yyyy-MM', new Date()); - const e = parse(endParam, 'yyyy-MM', new Date()); - if (isValid(s) && isValid(e)) { - startDate = new Date(s.getFullYear(), s.getMonth(), 1); - endDate = endOfMonth(e); - } else { - // Fallback - startDate = new Date(currentYear, 0, 1); - endDate = new Date(currentYear, 11, 31); - } - } else if (yearParam) { - const y = parseInt(yearParam); - startDate = new Date(y, 0, 1); - endDate = new Date(y, 11, 31); - } else { - startDate = new Date(currentYear, 0, 1); - endDate = new Date(currentYear, 11, 31); - } + // Arrows / "today" navigate in whole calendar years; the picker can still set + // an explicit custom range via start+end. See resolveRange. + const { startDate, endDate } = resolveRange( + { year: yearParam, start: startParam, end: endParam }, + new Date(), + ); // Show the login screen when there's no session, no token, OR the token diff --git a/src/components/CalendarHeader.tsx b/src/components/CalendarHeader.tsx index c4c0280..1410173 100644 --- a/src/components/CalendarHeader.tsx +++ b/src/components/CalendarHeader.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useRef, useEffect } from 'react'; -import { format, addYears, endOfMonth } from 'date-fns'; +import { format, endOfMonth } from 'date-fns'; import DatePicker, { registerLocale } from 'react-datepicker'; import { he } from 'date-fns/locale'; import "react-datepicker/dist/react-datepicker.css"; @@ -17,6 +17,7 @@ interface CalendarHeaderProps { endDate: Date; onJumpToToday: () => void; onChangeRange: (start: Date, end: Date) => void; + onChangeYear: (year: number) => void; showJewishCalendar: boolean; onToggleJewishCalendar: () => void; showHebrewDate: boolean; @@ -51,6 +52,7 @@ export default function CalendarHeader({ endDate, onJumpToToday, onChangeRange, + onChangeYear, showJewishCalendar, onToggleJewishCalendar, showHebrewDate, @@ -112,8 +114,8 @@ export default function CalendarHeader({
- - + +
{/* Range Picker */} diff --git a/src/components/LinearCalendar.tsx b/src/components/LinearCalendar.tsx index a8fc826..feb7ebe 100644 --- a/src/components/LinearCalendar.tsx +++ b/src/components/LinearCalendar.tsx @@ -485,25 +485,31 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e const toggleGridlines = () => setShowGridlines(prev => !prev); const toggleSeparators = () => setShowSeparators(prev => !prev); + // Navigate to a full calendar year (Jan..Dec) via the `year` param. + const changeYear = (year: number) => { + const params = new URLSearchParams(window.location.search); + params.delete('start'); + params.delete('end'); + params.set('year', String(year)); + router.push(`/?${params.toString()}`); + }; + const jumpToToday = () => { const today = new Date(); - const startStr = format(startDate, 'yyyy-MM-dd'); - const endStr = format(endDate, 'yyyy-MM-dd'); - const todayStr = format(today, 'yyyy-MM-dd'); - - if (todayStr >= startStr && todayStr <= endStr) { - const todayEl = document.getElementById('today-cell'); - if (todayEl) { - todayEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - } + const currentYear = today.getFullYear(); + // If we're already showing exactly the current calendar year, just scroll + // to today; otherwise navigate to this year's full Jan..Dec view. + const onCurrentYear = + startDate.getFullYear() === currentYear && startDate.getMonth() === 0 && + endDate.getFullYear() === currentYear && endDate.getMonth() === 11; + if (onCurrentYear) { + document.getElementById('today-cell')?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); } else { - // Navigate to current year - const currentYear = new Date().getFullYear(); - router.push(`/?year=${currentYear}`); + changeYear(currentYear); } }; - // Helper: Change Range + // Helper: Change Range (custom range from the date-range picker). const changeRange = (newStart: Date, newEnd: Date) => { const params = new URLSearchParams(window.location.search); params.delete('year'); @@ -602,6 +608,7 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e endDate={endDate} onJumpToToday={jumpToToday} onChangeRange={changeRange} + onChangeYear={changeYear} showJewishCalendar={showJewishCalendar} onToggleJewishCalendar={toggleJewishCalendar} showHebrewDate={showHebrewDate} diff --git a/src/lib/calendar-range.test.ts b/src/lib/calendar-range.test.ts new file mode 100644 index 0000000..0e8171d --- /dev/null +++ b/src/lib/calendar-range.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { eachMonthOfInterval } from 'date-fns'; +import { yearRange, resolveRange } from './calendar-range'; + +const monthCount = (r: { startDate: Date; endDate: Date }) => + eachMonthOfInterval({ start: r.startDate, end: r.endDate }).length; + +const TODAY = new Date(2026, 5, 11); // 2026-06-11 + +describe('yearRange', () => { + it('spans Jan 1 .. Dec 31 of the given year', () => { + const r = yearRange(2027); + expect(r.startDate).toEqual(new Date(2027, 0, 1)); + expect(r.endDate).toEqual(new Date(2027, 11, 31)); + }); + + it('is always exactly 12 months', () => { + for (const y of [2024, 2025, 2026, 2027, 2028]) { + expect(monthCount(yearRange(y))).toBe(12); + } + }); +}); + +describe('resolveRange — default and year', () => { + it('defaults to the current calendar year (12 months)', () => { + const r = resolveRange({}, TODAY); + expect(r.startDate).toEqual(new Date(2026, 0, 1)); + expect(r.endDate).toEqual(new Date(2026, 11, 31)); + expect(monthCount(r)).toBe(12); + }); + + it('year param yields exactly that calendar year (12 months)', () => { + expect(resolveRange({ year: '2027' }, TODAY)).toEqual(yearRange(2027)); + expect(monthCount(resolveRange({ year: '2028' }, TODAY))).toBe(12); + }); + + it('falls back to the current year for an invalid year param', () => { + expect(resolveRange({ year: 'abc' }, TODAY)).toEqual(yearRange(2026)); + }); +}); + +describe('resolveRange — custom start+end (date-range picker)', () => { + it('honors an explicit multi-month range (can exceed 12 months)', () => { + const r = resolveRange({ start: '2026-04', end: '2027-04' }, TODAY); + expect(r.startDate).toEqual(new Date(2026, 3, 1)); + // endOfMonth(Apr 2027) = Apr 30 (carries an end-of-day time component) + expect(r.endDate.getFullYear()).toBe(2027); + expect(r.endDate.getMonth()).toBe(3); + expect(r.endDate.getDate()).toBe(30); + expect(monthCount(r)).toBe(13); + }); + + it('falls back to the current year for an invalid custom range', () => { + expect(resolveRange({ start: 'nope', end: 'nope' }, TODAY)).toEqual(yearRange(2026)); + }); +}); + +describe('arrow navigation invariant (year ± 1 is always 12 months)', () => { + it('stepping forward/back from any year stays a clean 12-month year', () => { + // Arrows compute the target as startDate.getFullYear() ± 1, then the + // page resolves it via the year param -> yearRange. + const start = resolveRange({ start: '2026-04', end: '2027-04' }, TODAY); // a "bad" 13-month state + const forwardYear = start.startDate.getFullYear() + 1; // 2027 + const r = resolveRange({ year: String(forwardYear) }, TODAY); + expect(monthCount(r)).toBe(12); + expect(r).toEqual(yearRange(2027)); + }); +}); diff --git a/src/lib/calendar-range.ts b/src/lib/calendar-range.ts new file mode 100644 index 0000000..9de3c12 --- /dev/null +++ b/src/lib/calendar-range.ts @@ -0,0 +1,62 @@ +import { parse, endOfMonth, isValid } from 'date-fns'; + +/** + * Resolution of the calendar's visible date range. + * + * The view is driven by URL search params: + * - `start` + `end` (yyyy-MM): an explicit custom range (the date-range picker). + * - `year`: a full calendar year. + * - neither: defaults to the current calendar year. + * + * The `<` / `>` arrows and the "today" button operate in whole calendar years + * (always Jan..Dec), so navigation never drifts into an off-by-a-few-months + * window. Custom multi-month ranges remain available via the picker. + */ + +export interface DateRange { + startDate: Date; + endDate: Date; +} + +/** Full calendar year: Jan 1 .. Dec 31 of `year`. */ +export function yearRange(year: number): DateRange { + return { + startDate: new Date(year, 0, 1), + endDate: new Date(year, 11, 31), + }; +} + +export interface RangeParams { + year?: string | null; + start?: string | null; + end?: string | null; +} + +/** + * Resolve the visible range from URL params. Priority: explicit start+end, + * then `year`, then the current calendar year. Invalid params fall back to the + * current year. + */ +export function resolveRange(params: RangeParams, today: Date): DateRange { + const currentYear = today.getFullYear(); + + if (params.start && params.end) { + const s = parse(params.start, 'yyyy-MM', today); + const e = parse(params.end, 'yyyy-MM', today); + if (isValid(s) && isValid(e)) { + return { + startDate: new Date(s.getFullYear(), s.getMonth(), 1), + endDate: endOfMonth(e), + }; + } + return yearRange(currentYear); + } + + if (params.year) { + const y = parseInt(params.year, 10); + if (!Number.isNaN(y)) return yearRange(y); + return yearRange(currentYear); + } + + return yearRange(currentYear); +}