From 6d31c4bb5a1d573a057b73061da26a8aa6e7ec87 Mon Sep 17 00:00:00 2001 From: Sergey Shashkov Date: Sat, 27 Sep 2025 11:30:20 +0300 Subject: [PATCH 1/2] Add year shortcuts and auto-adjust month range --- examples/demo.json | 12 ++- src/components/Calendar.tsx | 8 +- src/components/CalendarTitle.tsx | 108 +++++++++++++++++++++++++-- src/components/SaveLoadData.tsx | 30 ++++++-- src/components/views/ClassicView.tsx | 20 ++--- src/components/views/ColumnView.tsx | 34 +++++---- src/components/views/LinearView.tsx | 22 ++++-- src/contexts/CalendarContext.tsx | 72 +++++++++--------- src/utils/monthRange.ts | 101 +++++++++++++++++++++++++ 9 files changed, 318 insertions(+), 89 deletions(-) create mode 100644 src/utils/monthRange.ts diff --git a/examples/demo.json b/examples/demo.json index 35e33fb..c283947 100644 --- a/examples/demo.json +++ b/examples/demo.json @@ -1,4 +1,14 @@ { + "monthRange": { + "start": { + "year": 2025, + "month": 0 + }, + "end": { + "year": 2025, + "month": 11 + } + }, "selectedYear": 2025, "dateCells": { "2024-11-31": { @@ -559,5 +569,5 @@ "selectedColorTexture": "yellow", "selectedView": "Linear", "exportDate": "2025-08-27T09:41:27.424Z", - "version": "2.0" + "version": "3.0" } \ No newline at end of file diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 646e53c..5f216c8 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -9,7 +9,7 @@ import LinearView from "./views/LinearView" import ViewSelector from "./ViewSelector" const Calendar: React.FC = () => { - const { selectedYear, dateCells, setDateCells, selectedColorTexture, selectedView, setSelectedView } = useCalendar() + const { monthRange, dateCells, setDateCells, selectedColorTexture, selectedView, setSelectedView } = useCalendar() return (
@@ -21,21 +21,21 @@ const Calendar: React.FC = () => { {selectedView === "Linear" ? ( ) : selectedView === "Classic" ? ( ) : ( { - const { selectedYear, setSelectedYear } = useCalendar() + const { monthRange, setMonthRange } = useCalendar() const currentYear = new Date().getFullYear() - const yearOptions = Array.from({ length: 7 }, (_, i) => currentYear - 1 + i) + const monthNames = MONTH_NAMES + + const minYear = Math.min(currentYear - 3, monthRange.start.year, monthRange.end.year) + const maxYear = Math.max(currentYear + 8, monthRange.start.year, monthRange.end.year) + + const monthOptions: { value: string; label: string }[] = [] + for (let year = minYear; year <= maxYear; year++) { + for (let month = 0; month < 12; month++) { + const pointer: MonthPointer = { year, month } + monthOptions.push({ + value: monthPointerToValue(pointer), + label: `${monthNames[month]} ${year}`, + }) + } + } + + const yearShortcutOptions: { value: string; label: string; key?: string }[] = [] + for (let year = 2025; year <= 2030; year++) { + const pointer: MonthPointer = { year, month: 0 } + yearShortcutOptions.push({ + value: monthPointerToValue(pointer), + label: `${year}`, + key: `year-${year}`, + }) + } + + const startOptions: { value: string; label: string; key?: string }[] = [ + ...yearShortcutOptions, + ...monthOptions, + ] + + const handleStartChange = (event: React.ChangeEvent) => { + const newStart = parseMonthPointerValue(event.target.value) + let newEnd = monthRange.end + + if (newStart.month === 0) { + newEnd = { year: newStart.year, month: 11 } + } else if (isAfter(newStart, newEnd)) { + newEnd = { ...newStart } + } + + setMonthRange({ start: newStart, end: newEnd }) + } + + const handleEndChange = (event: React.ChangeEvent) => { + const newEnd = parseMonthPointerValue(event.target.value) + let newStart = monthRange.start + + if (isAfter(newStart, newEnd)) { + newStart = { ...newEnd } + } + + setMonthRange({ start: newStart, end: newEnd }) + } + + const headerPrefix = "My plans from" return (

- My plans for{" "} + {headerPrefix}{" "} + + {" to "} diff --git a/src/components/SaveLoadData.tsx b/src/components/SaveLoadData.tsx index cc49f3f..92e81a5 100644 --- a/src/components/SaveLoadData.tsx +++ b/src/components/SaveLoadData.tsx @@ -1,15 +1,16 @@ import React, { useRef } from "react" import { useCalendar } from "../contexts/CalendarContext" import { UI_COLORS } from "../utils/colors" +import { createDefaultMonthRange, ensureValidRange, isValidMonthPointer } from "../utils/monthRange" const SaveLoadData: React.FC = () => { const { - selectedYear, + monthRange, dateCells, selectedColorTexture, selectedView, setDateCells, - setSelectedYear, + setMonthRange, setSelectedColorTexture, setSelectedView, } = useCalendar() @@ -18,12 +19,12 @@ const SaveLoadData: React.FC = () => { const handleSaveData = () => { const dataToSave = { - selectedYear, + monthRange, dateCells: Object.fromEntries(dateCells), selectedColorTexture, selectedView, exportDate: new Date().toISOString(), - version: "2.0", + version: "3.0", } const blob = new Blob([JSON.stringify(dataToSave, null, 2)], { @@ -72,8 +73,20 @@ const SaveLoadData: React.FC = () => { setDateCells(newDateCells) } - if (loadedData.selectedYear && typeof loadedData.selectedYear === "number") { - setSelectedYear(loadedData.selectedYear) + let resolvedMonthRange = monthRange + + if ( + loadedData.monthRange && + loadedData.monthRange.start && + loadedData.monthRange.end && + isValidMonthPointer(loadedData.monthRange.start) && + isValidMonthPointer(loadedData.monthRange.end) + ) { + resolvedMonthRange = ensureValidRange(loadedData.monthRange) + setMonthRange(resolvedMonthRange) + } else if (loadedData.selectedYear && typeof loadedData.selectedYear === "number") { + resolvedMonthRange = createDefaultMonthRange(loadedData.selectedYear) + setMonthRange(resolvedMonthRange) } if (loadedData.selectedColorTexture && typeof loadedData.selectedColorTexture === "string") { setSelectedColorTexture(loadedData.selectedColorTexture) @@ -97,10 +110,11 @@ const SaveLoadData: React.FC = () => { : Object.fromEntries(dateCells) const dataToSave = { - selectedYear: loadedData.selectedYear || selectedYear, + monthRange: resolvedMonthRange, dateCells: mergedDateCells, selectedColorTexture: loadedData.selectedColorTexture || selectedColorTexture, selectedView: loadedData.selectedView || selectedView, + version: "3.0", } localStorage.setItem("calendar_data", JSON.stringify(dataToSave)) } catch (error) { @@ -116,7 +130,7 @@ const SaveLoadData: React.FC = () => { const handleCleanAll = () => { if (window.confirm("Are you sure you want to delete all data? This action cannot be undone.")) { setDateCells(new Map()) - setSelectedYear(new Date().getFullYear()) + setMonthRange(createDefaultMonthRange(new Date().getFullYear())) setSelectedColorTexture("red") setSelectedView("Linear") diff --git a/src/components/views/ClassicView.tsx b/src/components/views/ClassicView.tsx index 4ad77a3..a300d3f 100644 --- a/src/components/views/ClassicView.tsx +++ b/src/components/views/ClassicView.tsx @@ -1,16 +1,17 @@ import { eachDayOfInterval, endOfMonth, format, getDay, startOfMonth } from "date-fns" import React, { useEffect, useState } from "react" import { applyColorToDate, ColorTextureCode, DateCellData, getDateKey, UI_COLORS } from "../../utils/colors" +import { enumerateMonths, MonthPointer, MonthRange, monthPointerToDate } from "../../utils/monthRange" import Day from "../Day" interface ClassicViewProps { - selectedYear: number + monthRange: MonthRange dateCells: Map setDateCells: (dateCells: Map) => void selectedColorTexture: ColorTextureCode } -const ClassicView: React.FC = ({ selectedYear, dateCells, setDateCells, selectedColorTexture }) => { +const ClassicView: React.FC = ({ monthRange, dateCells, setDateCells, selectedColorTexture }) => { const [isDragging, setIsDragging] = useState(false) const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] @@ -73,9 +74,10 @@ const ClassicView: React.FC = ({ selectedYear, dateCells, setD return day === 0 ? 6 : day - 1 // Sunday becomes 6, Monday becomes 0 } - const getWeeksForMonth = (month: number): Date[][] => { - const startDate = startOfMonth(new Date(selectedYear, month, 1)) - const endDate = endOfMonth(new Date(selectedYear, month, 1)) + const getWeeksForMonth = (monthPointer: MonthPointer): Date[][] => { + const baseDate = monthPointerToDate(monthPointer) + const startDate = startOfMonth(baseDate) + const endDate = endOfMonth(baseDate) const allDays = eachDayOfInterval({ start: startDate, end: endDate }) const weeks: Date[][] = [] @@ -99,11 +101,11 @@ const ClassicView: React.FC = ({ selectedYear, dateCells, setD return weeks } - 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 months = Array.from({ length: 12 }, (_, i) => i) + const months = enumerateMonths(monthRange) return (
= ({ selectedYear, dateCells, setD return (
setDateCells: (dateCells: Map) => void selectedColorTexture: ColorTextureCode } -const ColumnView: React.FC = ({ selectedYear, dateCells, setDateCells, selectedColorTexture }) => { +const ColumnView: React.FC = ({ monthRange, dateCells, setDateCells, selectedColorTexture }) => { const [isDragging, setIsDragging] = useState(false) const handleMouseDown = (date: Date) => { @@ -66,22 +67,23 @@ const ColumnView: React.FC = ({ 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..aa2d267 100644 --- a/src/contexts/CalendarContext.tsx +++ b/src/contexts/CalendarContext.tsx @@ -1,11 +1,12 @@ import React, { createContext, useContext, useEffect, useState } from "react" import { ColorTextureCode, DateCellData } from "../utils/colors" +import { createDefaultMonthRange, ensureValidRange, MonthRange, isValidMonthPointer } from "../utils/monthRange" type CalendarView = "Linear" | "Classic" | "Column" interface CalendarContextType { - selectedYear: number - setSelectedYear: (year: number) => void + monthRange: MonthRange + setMonthRange: (range: MonthRange) => void dateCells: Map setDateCells: (dateCells: Map) => void selectedColorTexture: ColorTextureCode @@ -21,9 +22,11 @@ interface CalendarProviderProps { } const STORAGE_KEY = "calendar_data" +const STORAGE_VERSION = "3.0" interface StoredData { - selectedYear: number + monthRange: MonthRange + selectedYear?: number dateCells: Record selectedColorTexture: ColorTextureCode selectedView: CalendarView @@ -33,7 +36,7 @@ interface StoredData { export const CalendarProvider: React.FC = ({ children }) => { const currentYear = new Date().getFullYear() - const [selectedYear, setSelectedYearState] = useState(currentYear) + const [monthRange, setMonthRangeState] = useState(createDefaultMonthRange(currentYear)) const [dateCells, setDateCellsState] = useState>(new Map()) const [selectedColorTexture, setSelectedColorTextureState] = useState("red") const [selectedView, setSelectedViewState] = useState("Linear") @@ -44,12 +47,17 @@ export const CalendarProvider: React.FC = ({ children }) if (storedData) { const parsedData: StoredData = JSON.parse(storedData) - if ( + if (parsedData.monthRange) { + const { start, end } = parsedData.monthRange + if (start && end && isValidMonthPointer(start) && isValidMonthPointer(end)) { + setMonthRangeState(ensureValidRange(parsedData.monthRange)) + } + } else if ( parsedData.selectedYear && parsedData.selectedYear >= currentYear - 1 && parsedData.selectedYear <= currentYear + 5 ) { - setSelectedYearState(parsedData.selectedYear) + setMonthRangeState(createDefaultMonthRange(parsedData.selectedYear)) } if (parsedData.dateCells) { @@ -70,57 +78,49 @@ export const CalendarProvider: React.FC = ({ children }) } }, [currentYear]) - const saveToLocalStorage = (data: StoredData) => { + const saveToLocalStorage = (overrides: Partial = {}) => { + const dataToSave: StoredData = { + monthRange, + dateCells: Object.fromEntries(dateCells), + selectedColorTexture, + selectedView, + version: STORAGE_VERSION, + ...overrides, + } + + dataToSave.version = STORAGE_VERSION + try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) + localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave)) } catch (error) { console.error("Error saving calendar data to localStorage:", error) } } - const setSelectedYear = (year: number) => { - setSelectedYearState(year) - saveToLocalStorage({ - selectedYear: year, - dateCells: Object.fromEntries(dateCells), - selectedColorTexture, - selectedView, - }) + const setMonthRange = (range: MonthRange) => { + const validRange = ensureValidRange(range) + setMonthRangeState(validRange) + saveToLocalStorage({ monthRange: validRange }) } const setDateCells = (newDateCells: Map) => { setDateCellsState(newDateCells) - saveToLocalStorage({ - selectedYear, - dateCells: Object.fromEntries(newDateCells), - selectedColorTexture, - selectedView, - }) + saveToLocalStorage({ dateCells: Object.fromEntries(newDateCells) }) } const setSelectedColorTexture = (colorTexture: ColorTextureCode) => { setSelectedColorTextureState(colorTexture) - saveToLocalStorage({ - selectedYear, - dateCells: Object.fromEntries(dateCells), - selectedColorTexture: colorTexture, - selectedView, - }) + saveToLocalStorage({ selectedColorTexture: colorTexture }) } const setSelectedView = (view: CalendarView) => { setSelectedViewState(view) - saveToLocalStorage({ - selectedYear, - dateCells: Object.fromEntries(dateCells), - selectedColorTexture, - selectedView: view, - }) + saveToLocalStorage({ selectedView: view }) } const value: CalendarContextType = { - selectedYear, - setSelectedYear, + monthRange, + setMonthRange, dateCells, setDateCells, selectedColorTexture, diff --git a/src/utils/monthRange.ts b/src/utils/monthRange.ts new file mode 100644 index 0000000..778afc1 --- /dev/null +++ b/src/utils/monthRange.ts @@ -0,0 +1,101 @@ +import { endOfMonth, startOfMonth } from "date-fns" + +export interface MonthPointer { + year: number + month: number // 0-based +} + +export interface MonthRange { + start: MonthPointer + end: MonthPointer +} + +export const isValidMonthPointer = (pointer: MonthPointer): boolean => { + return ( + Number.isInteger(pointer.year) && + Number.isInteger(pointer.month) && + pointer.month >= 0 && + pointer.month <= 11 + ) +} + +export const createDefaultMonthRange = (year: number): MonthRange => ({ + start: { year, month: 0 }, + end: { year, month: 11 }, +}) + +const toMonthIndex = (pointer: MonthPointer): number => pointer.year * 12 + pointer.month + +export const isBeforeOrEqual = (a: MonthPointer, b: MonthPointer): boolean => { + return toMonthIndex(a) <= toMonthIndex(b) +} + +export const isAfter = (a: MonthPointer, b: MonthPointer): boolean => { + return toMonthIndex(a) > toMonthIndex(b) +} + +export const normalizeMonthRange = (range: MonthRange): MonthRange => { + if (isAfter(range.start, range.end)) { + return { + start: { ...range.end }, + end: { ...range.start }, + } + } + + return { + start: { ...range.start }, + end: { ...range.end }, + } +} + +export const ensureValidRange = (range: MonthRange): MonthRange => { + const normalized = normalizeMonthRange(range) + return normalized +} + +export const monthPointerToDate = (pointer: MonthPointer): Date => { + return new Date(pointer.year, pointer.month, 1) +} + +export const getRangeInterval = (range: MonthRange): { start: Date; end: Date } => { + const normalized = normalizeMonthRange(range) + return { + start: startOfMonth(monthPointerToDate(normalized.start)), + end: endOfMonth(monthPointerToDate(normalized.end)), + } +} + +export const enumerateMonths = (range: MonthRange): MonthPointer[] => { + const normalized = normalizeMonthRange(range) + const months: MonthPointer[] = [] + + let currentYear = normalized.start.year + let currentMonth = normalized.start.month + + while (currentYear < normalized.end.year || (currentYear === normalized.end.year && currentMonth <= normalized.end.month)) { + months.push({ year: currentYear, month: currentMonth }) + currentMonth += 1 + if (currentMonth === 12) { + currentMonth = 0 + currentYear += 1 + } + } + + return months +} + +export const monthPointerToValue = (pointer: MonthPointer): string => { + return `${pointer.year}-${pointer.month}` +} + +export const parseMonthPointerValue = (value: string): MonthPointer => { + const [yearString, monthString] = value.split("-") + const year = parseInt(yearString, 10) + const month = parseInt(monthString, 10) + + if (Number.isNaN(year) || Number.isNaN(month)) { + throw new Error(`Invalid month pointer value: ${value}`) + } + + return { year, month } +} From 5e19ef2469859bc0fee0ccf93610b3f56644619a Mon Sep 17 00:00:00 2001 From: Sergey Shashkov Date: Mon, 6 Oct 2025 17:49:24 +0300 Subject: [PATCH 2/2] Add sharable calendar storage and switching --- src/components/Calendar.tsx | 2 + src/components/CalendarSharingControls.tsx | 118 ++++++ src/components/SaveLoadData.tsx | 22 -- src/contexts/CalendarContext.tsx | 431 ++++++++++++++++++--- src/utils/shareEncoding.ts | 54 +++ 5 files changed, 547 insertions(+), 80 deletions(-) create mode 100644 src/components/CalendarSharingControls.tsx create mode 100644 src/utils/shareEncoding.ts diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 5f216c8..cd603c2 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -1,6 +1,7 @@ import React from "react" import { useCalendar } from "../contexts/CalendarContext" import CalendarTitle from "./CalendarTitle" +import CalendarSharingControls from "./CalendarSharingControls" import ColorPicker from "./ColorPicker" import SaveLoadData from "./SaveLoadData" import ClassicView from "./views/ClassicView" @@ -15,6 +16,7 @@ const Calendar: React.FC = () => {
+
diff --git a/src/components/CalendarSharingControls.tsx b/src/components/CalendarSharingControls.tsx new file mode 100644 index 0000000..d74eaca --- /dev/null +++ b/src/components/CalendarSharingControls.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react" +import { useCalendar } from "../contexts/CalendarContext" +import { UI_COLORS } from "../utils/colors" +import { encodeCalendarPayload } from "../utils/shareEncoding" + +const CalendarSharingControls: React.FC = () => { + const { currentCalendarId, availableCalendars, switchCalendar, getCurrentDataSnapshot, isInitialized } = + useCalendar() + const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle") + + if (!isInitialized || !currentCalendarId) { + return null + } + + const handleShare = async () => { + const snapshot = getCurrentDataSnapshot() + if (!snapshot) return + + const encoded = encodeCalendarPayload(snapshot) + const baseUrl = `${window.location.origin}${window.location.pathname}` + const shareUrl = `${baseUrl}?calendar=${currentCalendarId}&data=${encoded}` + + try { + await navigator.clipboard.writeText(shareUrl) + setCopyState("copied") + window.setTimeout(() => setCopyState("idle"), 2500) + } catch (error) { + console.error("Failed to copy share link:", error) + setCopyState("error") + window.prompt("Copy this calendar link", shareUrl) + } + } + + const handleCalendarChange = (event: React.ChangeEvent) => { + const nextId = event.target.value + if (nextId && nextId !== currentCalendarId) { + switchCalendar(nextId) + } + } + + return ( +
+ + +
+ + {copyState === "copied" ? ( + Link copied! + ) : copyState === "error" ? ( + Copy failed, link shown in prompt. + ) : null} +
+
+ ) +} + +export default CalendarSharingControls diff --git a/src/components/SaveLoadData.tsx b/src/components/SaveLoadData.tsx index 92e81a5..7916bc7 100644 --- a/src/components/SaveLoadData.tsx +++ b/src/components/SaveLoadData.tsx @@ -95,28 +95,6 @@ const SaveLoadData: React.FC = () => { setSelectedView(loadedData.selectedView) } - const mergedDateCells = loadedData.dateCells - ? (() => { - const newDateCells = new Map(dateCells) - Object.entries(loadedData.dateCells).forEach(([dateKey, cellData]) => { - const existing = newDateCells.get(dateKey) || {} - newDateCells.set(dateKey, { - ...existing, - ...(cellData as any), - }) - }) - return Object.fromEntries(newDateCells) - })() - : Object.fromEntries(dateCells) - - const dataToSave = { - monthRange: resolvedMonthRange, - dateCells: mergedDateCells, - selectedColorTexture: loadedData.selectedColorTexture || selectedColorTexture, - selectedView: loadedData.selectedView || selectedView, - version: "3.0", - } - localStorage.setItem("calendar_data", JSON.stringify(dataToSave)) } catch (error) { alert("Error loading data: Invalid JSON format") console.error("Error parsing loaded data:", error) diff --git a/src/contexts/CalendarContext.tsx b/src/contexts/CalendarContext.tsx index aa2d267..49ad412 100644 --- a/src/contexts/CalendarContext.tsx +++ b/src/contexts/CalendarContext.tsx @@ -1,8 +1,39 @@ -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 { monthRange: MonthRange @@ -13,109 +44,388 @@ interface CalendarContextType { 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" -interface StoredData { - monthRange: MonthRange - selectedYear?: number - dateCells: Record - selectedColorTexture: ColorTextureCode - selectedView: CalendarView - version?: string +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 normalizeStoredData = (data: Partial | null | undefined, fallbackYear: number): StoredData => { + const defaultRange = createDefaultMonthRange(fallbackYear) + let monthRange = defaultRange + + 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 + } } -export const CalendarProvider: React.FC = ({ children }) => { +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: React.ReactNode }> = ({ children }) => { const currentYear = new Date().getFullYear() const [monthRange, setMonthRangeState] = useState(createDefaultMonthRange(currentYear)) const [dateCells, setDateCellsState] = useState>(new Map()) const [selectedColorTexture, setSelectedColorTextureState] = useState("red") const [selectedView, setSelectedViewState] = useState("Linear") + const [currentCalendarId, setCurrentCalendarId] = useState(null) + const [availableCalendars, setAvailableCalendars] = useState([]) + const [isInitialized, setIsInitialized] = useState(false) + + const registryRef = useRef(null) useEffect(() => { - try { - const storedData = localStorage.getItem(STORAGE_KEY) - if (storedData) { - const parsedData: StoredData = JSON.parse(storedData) - - if (parsedData.monthRange) { - const { start, end } = parsedData.monthRange - if (start && end && isValidMonthPointer(start) && isValidMonthPointer(end)) { - setMonthRangeState(ensureValidRange(parsedData.monthRange)) - } - } else if ( - parsedData.selectedYear && - parsedData.selectedYear >= currentYear - 1 && - parsedData.selectedYear <= currentYear + 5 - ) { - setMonthRangeState(createDefaultMonthRange(parsedData.selectedYear)) - } + if (typeof window === "undefined") return - if (parsedData.dateCells) { - const dateCellsMap = new Map(Object.entries(parsedData.dateCells)) - setDateCellsState(dateCellsMap) - } + const fallbackYear = currentYear + const nowIso = new Date().toISOString() + const legacyData = parseLegacyStoredData(localStorage.getItem(STORAGE_KEY), fallbackYear) + const storedRegistry = parseRegistry(localStorage.getItem(REGISTRY_STORAGE_KEY), fallbackYear) - if (parsedData.selectedColorTexture) { - setSelectedColorTextureState(parsedData.selectedColorTexture) + let registry = storedRegistry + if (!registry) { + const localId = generateCalendarId() + const baseData = legacyData ?? normalizeStoredData(null, fallbackYear) + registry = { + currentId: localId, + localId, + calendars: { + [localId]: { + id: localId, + data: baseData, + external: false, + lastUpdated: nowIso, + }, + }, + } + } else { + if (!registry.calendars[registry.localId]) { + const baseData = legacyData ?? normalizeStoredData(null, fallbackYear) + registry.calendars[registry.localId] = { + id: registry.localId, + data: baseData, + external: false, + lastUpdated: nowIso, } + } + } - if (parsedData.selectedView && ["Linear", "Classic", "Column"].includes(parsedData.selectedView)) { - setSelectedViewState(parsedData.selectedView) - } + const sharedCalendar = parseSharedCalendarFromUrl(fallbackYear) + if (sharedCalendar) { + registry.calendars[sharedCalendar.id] = { + id: sharedCalendar.id, + data: sharedCalendar.data, + external: sharedCalendar.id !== registry.localId, + lastUpdated: nowIso, } + registry.currentId = sharedCalendar.id + + try { + const url = new URL(window.location.href) + url.searchParams.set("calendar", sharedCalendar.id) + url.searchParams.delete("data") + window.history.replaceState({}, "", url.toString()) + } catch (error) { + console.error("Failed to clean shared calendar URL parameters:", error) + } + } else { + const requestedId = readCalendarIdFromUrl() + if (requestedId && registry.calendars[requestedId]) { + registry.currentId = requestedId + } else if (!registry.currentId || !registry.calendars[registry.currentId]) { + registry.currentId = registry.localId + } + } + + registryRef.current = registry + + const activeEntry = + registry.calendars[registry.currentId] ?? registry.calendars[registry.localId] + + const normalizedActive = normalizeStoredData(activeEntry?.data, fallbackYear) + setMonthRangeState(normalizedActive.monthRange) + setDateCellsState(new Map(Object.entries(normalizedActive.dateCells ?? {}))) + setSelectedColorTextureState(normalizedActive.selectedColorTexture) + setSelectedViewState(normalizedActive.selectedView) + setCurrentCalendarId(registry.currentId) + setAvailableCalendars(buildSummaries(registry, registry.currentId)) + + try { + localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(registry)) } catch (error) { - console.error("Error loading calendar data from localStorage:", error) + console.error("Error saving calendar registry to localStorage:", error) } + + const localEntry = registry.calendars[registry.localId] + if (localEntry) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(localEntry.data)) + } catch (error) { + console.error("Error saving primary calendar data to localStorage:", error) + } + } + + setIsInitialized(true) }, [currentYear]) - const saveToLocalStorage = (overrides: Partial = {}) => { - const dataToSave: StoredData = { - monthRange, + const persistRegistry = (registry: CalendarRegistry, activeId: string) => { + registryRef.current = registry + setAvailableCalendars(buildSummaries(registry, activeId)) + + try { + localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(registry)) + } catch (error) { + console.error("Error persisting calendar registry:", error) + } + + const localEntry = registry.calendars[registry.localId] + if (localEntry) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(localEntry.data)) + } catch (error) { + console.error("Error saving local calendar snapshot:", error) + } + } + } + + useEffect(() => { + if (!isInitialized) return + if (!currentCalendarId) return + + const registry = registryRef.current + if (!registry) return + + const snapshot: StoredData = { + monthRange: ensureValidRange(monthRange), dateCells: Object.fromEntries(dateCells), selectedColorTexture, selectedView, version: STORAGE_VERSION, - ...overrides, } - dataToSave.version = STORAGE_VERSION + const existingEntry = registry.calendars[currentCalendarId] + const updatedEntry: CalendarEntry = { + id: currentCalendarId, + data: snapshot, + external: currentCalendarId !== registry.localId ? existingEntry?.external ?? true : false, + lastUpdated: new Date().toISOString(), + } - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave)) - } catch (error) { - console.error("Error saving calendar data to localStorage:", error) + const updatedRegistry: CalendarRegistry = { + ...registry, + currentId: currentCalendarId, + calendars: { + ...registry.calendars, + [currentCalendarId]: updatedEntry, + }, } - } + + persistRegistry(updatedRegistry, currentCalendarId) + }, [monthRange, dateCells, selectedColorTexture, selectedView, currentCalendarId, isInitialized]) const setMonthRange = (range: MonthRange) => { - const validRange = ensureValidRange(range) - setMonthRangeState(validRange) - saveToLocalStorage({ monthRange: validRange }) + setMonthRangeState(ensureValidRange(range)) } const setDateCells = (newDateCells: Map) => { - setDateCellsState(newDateCells) - saveToLocalStorage({ dateCells: Object.fromEntries(newDateCells) }) + setDateCellsState(new Map(newDateCells)) } const setSelectedColorTexture = (colorTexture: ColorTextureCode) => { setSelectedColorTextureState(colorTexture) - saveToLocalStorage({ selectedColorTexture: colorTexture }) } const setSelectedView = (view: CalendarView) => { setSelectedViewState(view) - saveToLocalStorage({ selectedView: view }) + } + + const switchCalendar = (calendarId: string) => { + const registry = registryRef.current + if (!registry) return + const targetEntry = registry.calendars[calendarId] + if (!targetEntry) return + + const normalized = normalizeStoredData(targetEntry.data, monthRange.start.year) + const nextRegistry: CalendarRegistry = { + ...registry, + currentId: calendarId, + } + registryRef.current = nextRegistry + + setMonthRangeState(normalized.monthRange) + setDateCellsState(new Map(Object.entries(normalized.dateCells ?? {}))) + setSelectedColorTextureState(normalized.selectedColorTexture) + setSelectedViewState(normalized.selectedView) + setCurrentCalendarId(calendarId) + setAvailableCalendars(buildSummaries(nextRegistry, calendarId)) + + try { + const url = new URL(window.location.href) + url.searchParams.set("calendar", calendarId) + url.searchParams.delete("data") + window.history.replaceState({}, "", url.toString()) + } catch (error) { + console.error("Failed to update calendar id in URL:", error) + } + } + + const getCurrentDataSnapshot = (): StoredData | null => { + if (!currentCalendarId) return null + return { + monthRange: ensureValidRange(monthRange), + dateCells: Object.fromEntries(dateCells), + selectedColorTexture, + selectedView, + version: STORAGE_VERSION, + } } const value: CalendarContextType = { @@ -127,6 +437,11 @@ export const CalendarProvider: React.FC = ({ children }) setSelectedColorTexture, selectedView, setSelectedView, + currentCalendarId, + availableCalendars, + switchCalendar, + isInitialized, + getCurrentDataSnapshot, } return {children} diff --git a/src/utils/shareEncoding.ts b/src/utils/shareEncoding.ts new file mode 100644 index 0000000..835f9e4 --- /dev/null +++ b/src/utils/shareEncoding.ts @@ -0,0 +1,54 @@ +const BASE64_URL_SAFE_CHARS = { '/': '_', '+': '-', '=': '' } as const + +const toBase64Url = (input: string): string => { + return input.replace(/[+/=]/g, (char) => BASE64_URL_SAFE_CHARS[char as keyof typeof BASE64_URL_SAFE_CHARS]) +} + +const fromBase64Url = (input: string): string => { + let base64 = input.replace(/-/g, "+").replace(/_/g, "/") + const padding = base64.length % 4 + if (padding) { + base64 += "=".repeat(4 - padding) + } + return base64 +} + +const uint8ArrayToBase64 = (bytes: Uint8Array): string => { + let binary = "" + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + let chunkString = "" + for (let j = 0; j < chunk.length; j++) { + chunkString += String.fromCharCode(chunk[j]) + } + binary += chunkString + } + return btoa(binary) +} + +const base64ToUint8Array = (base64: string): Uint8Array => { + const binary = atob(base64) + const length = binary.length + const bytes = new Uint8Array(length) + for (let i = 0; i < length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +export const encodeCalendarPayload = (payload: unknown): string => { + const json = JSON.stringify(payload) + const encoder = new TextEncoder() + const bytes = encoder.encode(json) + const base64 = uint8ArrayToBase64(bytes) + return toBase64Url(base64) +} + +export const decodeCalendarPayload = (encoded: string): unknown => { + const base64 = fromBase64Url(encoded) + const bytes = base64ToUint8Array(base64) + const decoder = new TextDecoder() + const json = decoder.decode(bytes) + return JSON.parse(json) +}