Skip to content

Commit 7280f34

Browse files
committed
feat: implement url-driven calendar routing and navigation state
1 parent 817ca35 commit 7280f34

5 files changed

Lines changed: 203 additions & 62 deletions

File tree

apps/web/app/config/navigation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const navGroups: NavGroup[] = [
2323
id: "days",
2424
label: "Days",
2525
icon: CalendarDaysIcon,
26-
href: "/app",
26+
href: "/app/calendar",
2727
mobileVisible: true,
2828
variant: "highlight",
2929
},

apps/web/app/routes.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ export default [
77
route("*", "routes/marketing/not-found.tsx"),
88
]),
99

10-
// 2. Main App (Prefixed with /app or /dashboard)
10+
// 2. Main App (Prefixed with /app)
1111
route("app", "routes/app/_layout.tsx", [
12-
index("routes/app/dashboard.tsx"),
1312
route("settings", "routes/app/settings.tsx"),
13+
index("routes/app/dashboard.tsx"),
14+
route("calendar", "routes/app/dashboard.tsx", { id: "dashboard-calendar" }),
15+
route("calendar/*", "routes/app/dashboard.tsx", { id: "dashboard-calendar-splat" }),
1416
route("*", "routes/app/not-found.tsx"),
1517
]),
1618

apps/web/app/routes/app/dashboard/context/DashboardProvider.tsx

Lines changed: 135 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
1-
import {
2-
type ReactNode,
3-
useState,
4-
useMemo,
5-
useRef,
6-
useEffect,
7-
useCallback,
8-
startTransition,
9-
} from "react";
1+
import { type ReactNode, useState, useMemo, useRef, useEffect, useCallback } from "react";
2+
import { useParams, useNavigate } from "react-router";
103
import type { Action } from "@kreozalabs/kei-core";
114
import { useSettings } from "@/providers/SettingsContext";
125
import { useDb } from "@/providers/DbContext";
136
import { useCurrentDay } from "@/hooks/useCurrentDay";
14-
import { getTodayString, STORAGE_KEYS } from "@kreozalabs/kei-core";
7+
import { STORAGE_KEYS } from "@kreozalabs/kei-core";
158

169
import type { ViewMode } from "../types";
1710
import { useDashboardQueries } from "../hooks/useDashboardQueries";
@@ -21,51 +14,156 @@ import { DashboardContext, type DashboardContextValue } from "./DashboardContext
2114
export function DashboardProvider({ children }: { children: ReactNode }) {
2215
const { settings } = useSettings();
2316
const { isDbReady, dbError } = useDb();
17+
const params = useParams();
18+
const navigate = useNavigate();
2419

25-
const [viewMode, setViewModeState] = useState<ViewMode>("day");
2620
const todayStr = useCurrentDay();
27-
const [selectedDate, setSelectedDateState] = useState(getTodayString);
28-
const [startDateStr, setStartDateStr] = useState(getTodayString);
21+
22+
// Parse splat parameters
23+
const splatParts = useMemo(() => {
24+
const splat = params["*"] || "";
25+
return splat.split("/").filter(Boolean);
26+
}, [params]);
27+
28+
// 1. Derive viewMode
29+
const viewMode = useMemo(() => {
30+
const urlView = splatParts[0] as ViewMode | undefined;
31+
const validViews: ViewMode[] = ["day", "week", "month", "year", "agenda", "inbox", "lists"];
32+
if (urlView && validViews.includes(urlView)) {
33+
return urlView;
34+
}
35+
36+
if (typeof window !== "undefined") {
37+
const stored = localStorage.getItem("kei_dashboard_view_mode") as ViewMode;
38+
if (stored && validViews.includes(stored)) {
39+
return stored;
40+
}
41+
}
42+
return "day";
43+
}, [splatParts]);
44+
45+
// 2. Derive selectedDate
46+
const selectedDate = useMemo(() => {
47+
const year = splatParts[1];
48+
const month = splatParts[2];
49+
const day = splatParts[3];
50+
51+
if (year && month && day) {
52+
const y = year;
53+
const m = month.padStart(2, "0");
54+
const d = day.padStart(2, "0");
55+
const dateStr = `${y}-${m}-${d}`;
56+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(Date.parse(dateStr))) {
57+
return dateStr;
58+
}
59+
}
60+
61+
if (settings.remember_layout_on_refresh && typeof window !== "undefined") {
62+
const storedDate = localStorage.getItem(STORAGE_KEYS.LOCAL.SELECTED_DATE);
63+
if (storedDate && /^\d{4}-\d{2}-\d{2}$/.test(storedDate) && !isNaN(Date.parse(storedDate))) {
64+
return storedDate;
65+
}
66+
}
67+
68+
return todayStr;
69+
}, [splatParts, settings.remember_layout_on_refresh, todayStr]);
70+
71+
const [startDateStr, setStartDateStr] = useState(todayStr);
2972
const [endDateStr, setEndDateStr] = useState(() => {
30-
const d = new Date(getTodayString());
73+
const d = new Date(todayStr);
3174
d.setDate(d.getDate() + 3);
3275
return d.toISOString().split("T")[0];
3376
});
3477

35-
// Restore client-side state on mount to prevent SSR hydration mismatch warnings
78+
// Sync query bounds when selectedDate changes
3679
useEffect(() => {
37-
if (typeof window !== "undefined") {
38-
const storedViewMode = localStorage.getItem("kei_dashboard_view_mode") as ViewMode;
39-
if (storedViewMode) {
40-
setViewModeState(storedViewMode);
41-
}
80+
const isChronological = ["day", "week", "month", "year", "agenda"].includes(viewMode);
81+
if (!isChronological) {
82+
setStartDateStr(selectedDate);
83+
const d = new Date(selectedDate);
84+
d.setDate(d.getDate() + 3);
85+
setEndDateStr(d.toISOString().split("T")[0]);
86+
}
87+
}, [selectedDate, viewMode]);
4288

43-
if (settings.remember_layout_on_refresh) {
44-
const storedDate = localStorage.getItem(STORAGE_KEYS.LOCAL.SELECTED_DATE);
45-
if (storedDate) {
46-
setSelectedDateState(storedDate);
47-
}
89+
// Redirection / URL sync effect:
90+
// Ensure the browser URL matches the active viewMode and selectedDate.
91+
useEffect(() => {
92+
const isChronological = ["day", "week", "month", "year", "agenda"].includes(viewMode);
93+
94+
const urlView = splatParts[0];
95+
const urlYear = splatParts[1];
96+
const urlMonth = splatParts[2];
97+
const urlDay = splatParts[3];
98+
99+
if (isChronological) {
100+
const [y, m, d] = selectedDate.split("-");
101+
const formattedYear = y;
102+
const formattedMonth = String(Number(m));
103+
const formattedDay = String(Number(d));
104+
105+
if (
106+
urlView !== viewMode ||
107+
urlYear !== formattedYear ||
108+
urlMonth !== formattedMonth ||
109+
urlDay !== formattedDay
110+
) {
111+
navigate(`/app/calendar/${viewMode}/${formattedYear}/${formattedMonth}/${formattedDay}`, {
112+
replace: true,
113+
});
114+
}
115+
} else {
116+
// Structural views (inbox, lists)
117+
if (urlView !== viewMode || urlYear !== undefined) {
118+
navigate(`/app/calendar/${viewMode}`, { replace: true });
48119
}
49120
}
50-
}, [settings.remember_layout_on_refresh]);
121+
}, [viewMode, selectedDate, splatParts, navigate]);
51122

52-
const setViewMode = useCallback((val: ViewMode) => {
53-
startTransition(() => {
54-
setViewModeState(val);
55-
});
56-
if (typeof window !== "undefined") {
57-
window.localStorage.setItem("kei_dashboard_view_mode", val);
58-
}
59-
}, []);
123+
const setViewMode = useCallback(
124+
(val: ViewMode) => {
125+
if (typeof window !== "undefined") {
126+
window.localStorage.setItem("kei_dashboard_view_mode", val);
127+
}
128+
129+
const [y, m, d] = selectedDate.split("-");
130+
const formattedYear = y;
131+
const formattedMonth = String(Number(m));
132+
const formattedDay = String(Number(d));
133+
134+
const isChronological = ["day", "week", "month", "year", "agenda"].includes(val);
135+
if (isChronological) {
136+
navigate(`/app/calendar/${val}/${formattedYear}/${formattedMonth}/${formattedDay}`, {
137+
replace: true,
138+
});
139+
} else {
140+
navigate(`/app/calendar/${val}`, { replace: true });
141+
}
142+
},
143+
[selectedDate, navigate]
144+
);
60145

61146
const setSelectedDate = useCallback(
62147
(date: string) => {
63-
setSelectedDateState(date);
64-
if (settings.remember_layout_on_refresh) {
148+
if (settings.remember_layout_on_refresh && typeof window !== "undefined") {
65149
localStorage.setItem(STORAGE_KEYS.LOCAL.SELECTED_DATE, date);
66150
}
151+
152+
const [y, m, d] = date.split("-");
153+
const formattedYear = y;
154+
const formattedMonth = String(Number(m));
155+
const formattedDay = String(Number(d));
156+
157+
const urlYear = splatParts[1];
158+
const urlMonth = splatParts[2];
159+
const urlDay = splatParts[3];
160+
161+
if (urlYear !== formattedYear || urlMonth !== formattedMonth || urlDay !== formattedDay) {
162+
const dest = `/app/calendar/${viewMode}/${formattedYear}/${formattedMonth}/${formattedDay}`;
163+
navigate(dest, { replace: true });
164+
}
67165
},
68-
[settings.remember_layout_on_refresh]
166+
[viewMode, splatParts, settings.remember_layout_on_refresh, navigate]
69167
);
70168

71169
const queries = useDashboardQueries({

apps/web/app/routes/app/dashboard/views/CalendarView.tsx

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useMemo } from "react";
1+
import { useState, useEffect, useMemo, useRef } from "react";
22
import { createPortal } from "react-dom";
33
import { IlamyCalendar, useIlamyCalendarContext } from "@ilamy/calendar";
44
import { recurrencePlugin } from "@ilamy/calendar/plugins/recurrence";
@@ -76,6 +76,7 @@ function getDisplayDate(dateObj: Date, viewMode: string, localeCode?: string): s
7676
function TodayButton({ onClick }: { onClick?: () => void }) {
7777
const api = useIlamyCalendarContext();
7878
const isMobile = useMediaQuery("(max-width: 768px)");
79+
const { setSelectedDate } = useDashboardContext();
7980

8081
// Get today's day number (e.g., 11)
8182
const todayElement = new Date().getDate();
@@ -87,6 +88,11 @@ function TodayButton({ onClick }: { onClick?: () => void }) {
8788
className={cn("", isMobile ? "" : "rounded-3xl p-5 text-sm font-medium")}
8889
onClick={() => {
8990
api.today();
91+
const d = new Date();
92+
const y = d.getFullYear();
93+
const m = String(d.getMonth() + 1).padStart(2, "0");
94+
const day = String(d.getDate()).padStart(2, "0");
95+
setSelectedDate(`${y}-${m}-${day}`);
9096
if (onClick) onClick();
9197
}}
9298
>
@@ -136,7 +142,7 @@ interface CalendarHeaderControlsProps {
136142
function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsProps) {
137143
const api = useIlamyCalendarContext();
138144
const { settings } = useSettings();
139-
const { viewMode } = useDashboardContext();
145+
const { viewMode, selectedDate, setSelectedDate } = useDashboardContext();
140146
const isMobile = useMediaQuery("(max-width: 768px)");
141147
const [slideDirection, setSlideDirection] = useState<-1 | 1>(1);
142148

@@ -145,12 +151,33 @@ function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsPro
145151
const [mobileTodayTarget, setMobileTodayTarget] = useState<HTMLElement | null>(null);
146152
const [mobileDropdownTarget, setMobileDropdownTarget] = useState<HTMLElement | null>(null);
147153

154+
const lastSelectedDateRef = useRef("");
155+
148156
useEffect(() => {
149157
if (api.view !== viewMode) {
150158
api.setView(viewMode as string);
151159
}
152160
}, [viewMode, api]);
153161

162+
useEffect(() => {
163+
if (lastSelectedDateRef.current !== selectedDate) {
164+
lastSelectedDateRef.current = selectedDate;
165+
166+
const formatted = api.currentDate.format("YYYY-MM-DD");
167+
if (formatted !== selectedDate) {
168+
const parts = selectedDate.split("-").map(Number);
169+
if (parts.length === 3 && !isNaN(parts[0]) && !isNaN(parts[1]) && !isNaN(parts[2])) {
170+
api.setCurrentDate(
171+
api.currentDate
172+
.year(parts[0])
173+
.month(parts[1] - 1)
174+
.date(parts[2])
175+
);
176+
}
177+
}
178+
}
179+
}, [selectedDate, api]);
180+
154181
useEffect(() => {
155182
const dt = document.getElementById("calendar-desktop-controls-target");
156183
const mtt = document.getElementById("calendar-mobile-trigger-target");
@@ -170,23 +197,29 @@ function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsPro
170197
const currentDateMs = api.currentDate.valueOf();
171198
const [visibleMonth, setVisibleMonth] = useState<Date>(selectedDateObj);
172199

200+
const lastCurrentDateMsRef = useRef(currentDateMs);
201+
173202
useEffect(() => {
174-
const newDate = new Date(currentDateMs);
175-
const prevDate = visibleMonth;
176-
177-
const prevYear = prevDate.getFullYear();
178-
const prevMonth = prevDate.getMonth();
179-
const newYear = newDate.getFullYear();
180-
const newMonth = newDate.getMonth();
181-
182-
if (prevYear !== newYear || prevMonth !== newMonth) {
183-
const prevTotalMonths = prevYear * 12 + prevMonth;
184-
const newTotalMonths = newYear * 12 + newMonth;
185-
const direction = newTotalMonths > prevTotalMonths ? 1 : -1;
186-
setSlideDirection(direction);
187-
}
203+
if (lastCurrentDateMsRef.current !== currentDateMs) {
204+
lastCurrentDateMsRef.current = currentDateMs;
205+
206+
const newDate = new Date(currentDateMs);
207+
const prevDate = visibleMonth;
208+
209+
const prevYear = prevDate.getFullYear();
210+
const prevMonth = prevDate.getMonth();
211+
const newYear = newDate.getFullYear();
212+
const newMonth = newDate.getMonth();
213+
214+
if (prevYear !== newYear || prevMonth !== newMonth) {
215+
const prevTotalMonths = prevYear * 12 + prevMonth;
216+
const newTotalMonths = newYear * 12 + newMonth;
217+
const direction = newTotalMonths > prevTotalMonths ? 1 : -1;
218+
setSlideDirection(direction);
219+
}
188220

189-
setVisibleMonth(newDate);
221+
setVisibleMonth(newDate);
222+
}
190223
}, [currentDateMs, visibleMonth]);
191224

192225
const displayDate = getDisplayDate(
@@ -197,9 +230,10 @@ function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsPro
197230

198231
const handleSelect = (date: Date | undefined) => {
199232
if (date) {
200-
api.setCurrentDate(
201-
api.currentDate.year(date.getFullYear()).month(date.getMonth()).date(date.getDate())
202-
);
233+
const y = date.getFullYear();
234+
const m = String(date.getMonth() + 1).padStart(2, "0");
235+
const d = String(date.getDate()).padStart(2, "0");
236+
setSelectedDate(`${y}-${m}-${d}`);
203237
if (!isMobile) {
204238
setIsOpen(false);
205239
}
@@ -208,12 +242,14 @@ function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsPro
208242

209243
const handlePrev = () => {
210244
const unit = VIEW_MODE_UNITS[viewMode] || "day";
211-
api.setCurrentDate(api.currentDate.subtract(1, unit));
245+
const newDate = api.currentDate.subtract(1, unit);
246+
setSelectedDate(newDate.format("YYYY-MM-DD"));
212247
};
213248

214249
const handleNext = () => {
215250
const unit = VIEW_MODE_UNITS[viewMode] || "day";
216-
api.setCurrentDate(api.currentDate.add(1, unit));
251+
const newDate = api.currentDate.add(1, unit);
252+
setSelectedDate(newDate.format("YYYY-MM-DD"));
217253
};
218254

219255
const handleSwipePrev = () => {
@@ -393,6 +429,8 @@ function CalendarHeaderControls({ isOpen, setIsOpen }: CalendarHeaderControlsPro
393429
mode="single"
394430
selected={selectedDateObj}
395431
onSelect={handleSelect}
432+
month={visibleMonth}
433+
onMonthChange={setVisibleMonth}
396434
lang={localeCode}
397435
className="[--cell-size:--spacing(10)]"
398436
/>

apps/web/app/routes/app/not-found.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { MobileFAB } from "@/components/MobileFAB";
66
export default function AppNotFound() {
77
useEffect(() => {
88
document.title = "Kei - Not Found";
9+
if (typeof window !== "undefined") {
10+
console.log("[AppNotFound] 404 hit for path:", window.location.pathname);
11+
}
912
}, []);
1013

1114
return (

0 commit comments

Comments
 (0)