= ({ selectedYear, dateCells, setDat
setDateCells(newDateCells)
}
- const getDaysForMonth = (month: number): Date[] => {
- const startDate = startOfMonth(new Date(selectedYear, month, 1))
- const endDate = endOfMonth(new Date(selectedYear, month, 1))
+ const getDaysForMonth = (monthPointer: MonthPointer): Date[] => {
+ const baseDate = monthPointerToDate(monthPointer)
+ const startDate = startOfMonth(baseDate)
+ const endDate = endOfMonth(baseDate)
return eachDayOfInterval({ start: startDate, end: endDate })
}
- const getMonthName = (month: number): string => {
- return format(new Date(selectedYear, month, 1), "MMMM")
+ const getMonthName = (monthPointer: MonthPointer): string => {
+ return format(monthPointerToDate(monthPointer), "MMMM yyyy")
}
- const getMaxDaysInYear = (): number => {
- return Math.max(...Array.from({ length: 12 }, (_, i) => getDaysForMonth(i).length))
- }
+ const months = enumerateMonths(monthRange)
- const months = Array.from({ length: 12 }, (_, i) => i)
- const maxDays = getMaxDaysInYear()
+ const getMaxDaysInRange = (): number => {
+ return Math.max(...months.map((month) => getDaysForMonth(month).length))
+ }
+ const maxDays = getMaxDaysInRange()
return (
= ({ selectedYear, dateCells, setDat
{months.map((month) => (
= ({ selectedYear, dateCells, setDat
fontSize: "16px",
borderRight: `1px solid ${UI_COLORS.border.secondary}`,
backgroundColor: UI_COLORS.background.secondary,
- width: `${100 / 12}%`, // Equal width for all columns
+ width: `${100 / months.length}%`,
}}
>
{getMonthName(month)}
@@ -129,7 +131,7 @@ const ColumnView: React.FC = ({ selectedYear, dateCells, setDat
if (!day) {
return (
| = ({ selectedYear, dateCells, setDat
return (
|
setDateCells: (dateCells: Map) => void
selectedColorTexture: ColorTextureCode
}
-const LinearView: React.FC = ({ selectedYear, dateCells, setDateCells, selectedColorTexture }) => {
+const LinearView: React.FC = ({ monthRange, dateCells, setDateCells, selectedColorTexture }) => {
const [isDragging, setIsDragging] = useState(false)
- const year = selectedYear
- const startDate = startOfYear(new Date(year, 0, 1))
- const endDate = endOfYear(new Date(year, 11, 31))
+ const { start: startDate, end: endDate } = getRangeInterval(monthRange)
const allDays = eachDayOfInterval({ start: startDate, end: endDate })
@@ -132,7 +131,16 @@ const LinearView: React.FC = ({ selectedYear, dateCells, setDat
}
const getMonthName = (date: Date): string => {
- return format(date, "MMMM")
+ const monthLabel = format(date, "MMMM")
+ const isStartMonth =
+ date.getFullYear() === monthRange.start.year && date.getMonth() === monthRange.start.month
+ const isJanuary = date.getMonth() === 0
+
+ if (isStartMonth || isJanuary) {
+ return `${monthLabel} ${date.getFullYear()}`
+ }
+
+ return monthLabel
}
const shouldShowMonthName = (week: Date[]): string | null => {
diff --git a/src/contexts/CalendarContext.tsx b/src/contexts/CalendarContext.tsx
index 90d325a..49ad412 100644
--- a/src/contexts/CalendarContext.tsx
+++ b/src/contexts/CalendarContext.tsx
@@ -1,132 +1,447 @@
-import React, { createContext, useContext, useEffect, useState } from "react"
+import React, { createContext, useContext, useEffect, useRef, useState } from "react"
import { ColorTextureCode, DateCellData } from "../utils/colors"
+import { createDefaultMonthRange, ensureValidRange, MonthRange, isValidMonthPointer } from "../utils/monthRange"
+import { decodeCalendarPayload } from "../utils/shareEncoding"
-type CalendarView = "Linear" | "Classic" | "Column"
+export type CalendarView = "Linear" | "Classic" | "Column"
+
+export interface StoredData {
+ monthRange: MonthRange
+ selectedYear?: number
+ dateCells: Record
+ selectedColorTexture: ColorTextureCode
+ selectedView: CalendarView
+ version?: string
+}
+
+interface CalendarEntry {
+ id: string
+ data: StoredData
+ external: boolean
+ lastUpdated: string
+}
+
+interface CalendarRegistry {
+ currentId: string
+ localId: string
+ calendars: Record
+}
+
+export interface CalendarSummary {
+ id: string
+ label: string
+ external: boolean
+ lastUpdated: string
+ isCurrent: boolean
+}
interface CalendarContextType {
- selectedYear: number
- setSelectedYear: (year: number) => void
+ monthRange: MonthRange
+ setMonthRange: (range: MonthRange) => void
dateCells: Map
setDateCells: (dateCells: Map) => void
selectedColorTexture: ColorTextureCode
setSelectedColorTexture: (colorTexture: ColorTextureCode) => void
selectedView: CalendarView
setSelectedView: (view: CalendarView) => void
+ currentCalendarId: string | null
+ availableCalendars: CalendarSummary[]
+ switchCalendar: (calendarId: string) => void
+ isInitialized: boolean
+ getCurrentDataSnapshot: () => StoredData | null
}
const CalendarContext = createContext(undefined)
-interface CalendarProviderProps {
- children: React.ReactNode
+const STORAGE_KEY = "calendar_data"
+const REGISTRY_STORAGE_KEY = "calendar_registry"
+const STORAGE_VERSION = "3.0"
+
+const generateCalendarId = (): string => {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 12)
+ }
+ return Math.random().toString(36).slice(2, 14)
}
-const STORAGE_KEY = "calendar_data"
+const normalizeStoredData = (data: Partial | null | undefined, fallbackYear: number): StoredData => {
+ const defaultRange = createDefaultMonthRange(fallbackYear)
+ let monthRange = defaultRange
-interface StoredData {
- selectedYear: number
- dateCells: Record
- selectedColorTexture: ColorTextureCode
- selectedView: CalendarView
- version?: string
+ if (data?.monthRange && data.monthRange.start && data.monthRange.end) {
+ const { start, end } = data.monthRange
+ if (isValidMonthPointer(start) && isValidMonthPointer(end)) {
+ monthRange = ensureValidRange(data.monthRange)
+ }
+ } else if (typeof data?.selectedYear === "number") {
+ monthRange = createDefaultMonthRange(data.selectedYear)
+ }
+
+ const dateCells =
+ data?.dateCells && typeof data.dateCells === "object" ? (data.dateCells as Record) : {}
+
+ const selectedColorTexture =
+ data?.selectedColorTexture && typeof data.selectedColorTexture === "string"
+ ? (data.selectedColorTexture as ColorTextureCode)
+ : "red"
+
+ const selectedView =
+ data?.selectedView && ["Linear", "Classic", "Column"].includes(data.selectedView)
+ ? (data.selectedView as CalendarView)
+ : "Linear"
+
+ return {
+ monthRange,
+ dateCells,
+ selectedColorTexture,
+ selectedView,
+ version: STORAGE_VERSION,
+ }
+}
+
+const parseLegacyStoredData = (raw: string | null, fallbackYear: number): StoredData | null => {
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw)
+ if (!parsed || typeof parsed !== "object") return null
+ return normalizeStoredData(parsed as Partial, fallbackYear)
+ } catch (error) {
+ console.error("Error parsing legacy calendar data:", error)
+ return null
+ }
+}
+
+const parseRegistry = (raw: string | null, fallbackYear: number): CalendarRegistry | null => {
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw)
+ if (!parsed || typeof parsed !== "object") {
+ return null
+ }
+
+ const localId = typeof parsed.localId === "string" && parsed.localId.trim() ? parsed.localId : generateCalendarId()
+ const calendars: Record = {}
+ const nowIso = new Date().toISOString()
+
+ if (parsed.calendars && typeof parsed.calendars === "object") {
+ for (const [id, entryValue] of Object.entries(parsed.calendars as Record)) {
+ if (!id || typeof entryValue !== "object" || entryValue === null) continue
+
+ const entryObject = entryValue as { data?: StoredData; external?: boolean; lastUpdated?: string }
+ const normalizedData = normalizeStoredData(entryObject.data ?? (entryObject as Partial), fallbackYear)
+ calendars[id] = {
+ id,
+ data: normalizedData,
+ external: Boolean(entryObject.external && id !== localId),
+ lastUpdated: typeof entryObject.lastUpdated === "string" ? entryObject.lastUpdated : nowIso,
+ }
+ }
+ }
+
+ const currentId =
+ typeof parsed.currentId === "string" && parsed.currentId in calendars ? parsed.currentId : localId
+
+ return {
+ currentId,
+ localId,
+ calendars,
+ }
+ } catch (error) {
+ console.error("Error parsing calendar registry:", error)
+ return null
+ }
+}
+
+const buildSummaries = (registry: CalendarRegistry, activeId: string): CalendarSummary[] => {
+ const entries = Object.values(registry.calendars)
+ entries.sort((a, b) => {
+ if (a.id === registry.localId && b.id !== registry.localId) return -1
+ if (a.id !== registry.localId && b.id === registry.localId) return 1
+ return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
+ })
+
+ return entries.map((entry) => ({
+ id: entry.id,
+ label: entry.external ? `Shared ${entry.id.slice(0, 6)}` : "My calendar",
+ external: entry.external,
+ lastUpdated: entry.lastUpdated,
+ isCurrent: entry.id === activeId,
+ }))
+}
+
+const readCalendarIdFromUrl = (): string | null => {
+ if (typeof window === "undefined") return null
+ try {
+ const url = new URL(window.location.href)
+ const calendarId = url.searchParams.get("calendar")
+ return calendarId && calendarId.trim() ? calendarId : null
+ } catch (error) {
+ console.error("Error reading calendar id from URL:", error)
+ return null
+ }
+}
+
+const parseSharedCalendarFromUrl = (fallbackYear: number): { id: string; data: StoredData } | null => {
+ if (typeof window === "undefined") return null
+
+ try {
+ const url = new URL(window.location.href)
+ const calendarId = url.searchParams.get("calendar")
+ const encodedData = url.searchParams.get("data")
+
+ if (!calendarId || !encodedData) {
+ return null
+ }
+
+ const decoded = decodeCalendarPayload(encodedData)
+ if (!decoded || typeof decoded !== "object") {
+ return null
+ }
+
+ const normalized = normalizeStoredData(decoded as Partial, fallbackYear)
+ return {
+ id: calendarId,
+ data: normalized,
+ }
+ } catch (error) {
+ console.error("Failed to parse shared calendar from URL:", error)
+ return null
+ }
}
-export const CalendarProvider: React.FC = ({ children }) => {
+export const CalendarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const currentYear = new Date().getFullYear()
- const [selectedYear, setSelectedYearState] = useState(currentYear)
+ const [monthRange, setMonthRangeState] = useState(createDefaultMonthRange(currentYear))
const [dateCells, setDateCellsState] = useState | |