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);
+}