Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 7 additions & 26 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/components/CalendarHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -51,6 +52,7 @@ export default function CalendarHeader({
endDate,
onJumpToToday,
onChangeRange,
onChangeYear,
showJewishCalendar,
onToggleJewishCalendar,
showHebrewDate,
Expand Down Expand Up @@ -112,8 +114,8 @@ export default function CalendarHeader({

<button onClick={onJumpToToday} className={styles.todayButton}>היום</button>
<div className={styles.yearControls}>
<button onClick={() => onChangeRange(addYears(startDate, -1), addYears(endDate, -1))} className={styles.yearButton}>&lt;</button>
<button onClick={() => onChangeRange(addYears(startDate, 1), addYears(endDate, 1))} className={styles.yearButton}>&gt;</button>
<button onClick={() => onChangeYear(startDate.getFullYear() - 1)} className={styles.yearButton} title="שנה קודמת">&lt;</button>
<button onClick={() => onChangeYear(startDate.getFullYear() + 1)} className={styles.yearButton} title="שנה הבאה">&gt;</button>
</div>

{/* Range Picker */}
Expand Down
33 changes: 20 additions & 13 deletions src/components/LinearCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}
Expand Down
68 changes: 68 additions & 0 deletions src/lib/calendar-range.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
62 changes: 62 additions & 0 deletions src/lib/calendar-range.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading