Skip to content

Commit 99109c7

Browse files
committed
Add calendar view range highlighting
1 parent e63288e commit 99109c7

4 files changed

Lines changed: 129 additions & 75 deletions

File tree

Lines changed: 102 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMemo } from 'react';
12
import type { FC } from 'react';
23
import { Button, Calendar } from 'antd';
34
import {
@@ -8,65 +9,113 @@ import {
89
RightOutlined,
910
} from '@ant-design/icons';
1011
import dayjs from 'dayjs';
12+
import type { CalendarView } from '../../contexts/CalendarDateContext';
1113

1214
interface Props {
1315
selectedDate: Date;
1416
onSelect: (date: Date) => void;
17+
currentView: CalendarView;
18+
weekStartDay: number;
1519
}
1620

17-
const MiniCalendar: FC<Props> = ({ selectedDate, onSelect }) => (
18-
<div className="mini-cal" style={{ padding: '4px 8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
19-
<Calendar
20-
fullscreen={false}
21-
value={dayjs(selectedDate)}
22-
onSelect={(date) => onSelect(date.toDate())}
23-
headerRender={({ value, onChange }) => (
24-
<div style={{ padding: '4px 0' }}>
25-
<div style={{ textAlign: 'center', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
26-
{value.format('MMMM YYYY')}
27-
</div>
28-
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
29-
<Button
30-
type="text"
31-
size="small"
32-
icon={<DoubleLeftOutlined />}
33-
onClick={() => onChange(value.subtract(1, 'year'))}
34-
aria-label="Previous year"
35-
/>
36-
<Button
37-
type="text"
38-
size="small"
39-
icon={<LeftOutlined />}
40-
onClick={() => onChange(value.subtract(1, 'month'))}
41-
aria-label="Previous month"
42-
/>
43-
<Button
44-
type="text"
45-
size="small"
46-
onClick={() => onChange(dayjs())}
47-
aria-label="Today"
48-
>
49-
<CalendarOutlined />
50-
</Button>
51-
<Button
52-
type="text"
53-
size="small"
54-
icon={<RightOutlined />}
55-
onClick={() => onChange(value.add(1, 'month'))}
56-
aria-label="Next month"
57-
/>
58-
<Button
59-
type="text"
60-
size="small"
61-
icon={<DoubleRightOutlined />}
62-
onClick={() => onChange(value.add(1, 'year'))}
63-
aria-label="Next year"
64-
/>
21+
const MiniCalendar: FC<Props> = ({ selectedDate, onSelect, currentView, weekStartDay }) => {
22+
const selected = dayjs(selectedDate);
23+
24+
const rangeStart = useMemo(() => {
25+
switch (currentView) {
26+
case 'week': {
27+
const diff = (selected.day() - weekStartDay + 7) % 7;
28+
return selected.subtract(diff, 'day').startOf('day');
29+
}
30+
case '3day':
31+
return selected.startOf('day');
32+
case 'month':
33+
return selected.startOf('month');
34+
default:
35+
return selected.startOf('day');
36+
}
37+
}, [selected, currentView, weekStartDay]);
38+
39+
const rangeEnd = useMemo(() => {
40+
switch (currentView) {
41+
case 'week':
42+
return rangeStart.add(6, 'day').endOf('day');
43+
case '3day':
44+
return selected.add(2, 'day').endOf('day');
45+
case 'month':
46+
return selected.endOf('month');
47+
default:
48+
return selected.endOf('day');
49+
}
50+
}, [selected, currentView, rangeStart]);
51+
52+
return (
53+
<div className="mini-cal" style={{ padding: '4px 8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
54+
<Calendar
55+
fullscreen={false}
56+
value={selected}
57+
onSelect={(date) => onSelect(date.toDate())}
58+
fullCellRender={(current, info) => {
59+
if (info.type !== 'date') return info.originNode;
60+
const inRange =
61+
currentView !== 'day' &&
62+
!current.isBefore(rangeStart, 'day') &&
63+
!current.isAfter(rangeEnd, 'day');
64+
if (!inRange) return info.originNode;
65+
return (
66+
<div style={{ background: 'rgba(22, 119, 255, 0.12)', borderRadius: 4 }}>
67+
{info.originNode}
68+
</div>
69+
);
70+
}}
71+
headerRender={({ value, onChange }) => (
72+
<div style={{ padding: '4px 0' }}>
73+
<div style={{ textAlign: 'center', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
74+
{value.format('MMMM YYYY')}
75+
</div>
76+
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
77+
<Button
78+
type="text"
79+
size="small"
80+
icon={<DoubleLeftOutlined />}
81+
onClick={() => onChange(value.subtract(1, 'year'))}
82+
aria-label="Previous year"
83+
/>
84+
<Button
85+
type="text"
86+
size="small"
87+
icon={<LeftOutlined />}
88+
onClick={() => onChange(value.subtract(1, 'month'))}
89+
aria-label="Previous month"
90+
/>
91+
<Button
92+
type="text"
93+
size="small"
94+
onClick={() => onChange(dayjs())}
95+
aria-label="Today"
96+
>
97+
<CalendarOutlined />
98+
</Button>
99+
<Button
100+
type="text"
101+
size="small"
102+
icon={<RightOutlined />}
103+
onClick={() => onChange(value.add(1, 'month'))}
104+
aria-label="Next month"
105+
/>
106+
<Button
107+
type="text"
108+
size="small"
109+
icon={<DoubleRightOutlined />}
110+
onClick={() => onChange(value.add(1, 'year'))}
111+
aria-label="Next year"
112+
/>
113+
</div>
65114
</div>
66-
</div>
67-
)}
68-
/>
69-
</div>
70-
);
115+
)}
116+
/>
117+
</div>
118+
);
119+
};
71120

72121
export default MiniCalendar;

src/TimeHacker.UI/src/components/Layout/index.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next';
2323

2424
import { useAuth } from 'contexts/AuthContext';
2525
import { useCalendarDate } from 'contexts/CalendarDateContext';
26+
import { useSettings } from 'contexts/SettingsContext';
2627
import { fetchTasksForDay } from '../../api/tasks';
2728
import { useIsMobile } from '../../hooks/useIsMobile';
2829
import SidebarLogo from './SidebarLogo';
@@ -37,7 +38,9 @@ const Layout: FC = () => {
3738
const navigate = useNavigate();
3839
const { isMobile } = useIsMobile();
3940
const { t } = useTranslation();
40-
const { selectedDate, setSelectedDate } = useCalendarDate();
41+
const { selectedDate, setSelectedDate, calendarView } = useCalendarDate();
42+
const { weekStart } = useSettings();
43+
const weekStartDay = weekStart === 'monday' ? 1 : 0;
4144

4245
const [collapsed, setCollapsed] = useState(false);
4346
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -91,7 +94,7 @@ const Layout: FC = () => {
9194
>
9295
<SidebarLogo onClick={handleLogoClick} />
9396
{menuNode}
94-
<MiniCalendar selectedDate={selectedDate} onSelect={handleMiniCalendarSelect} />
97+
<MiniCalendar selectedDate={selectedDate} onSelect={handleMiniCalendarSelect} currentView={calendarView} weekStartDay={weekStartDay} />
9598
</Drawer>
9699
) : (
97100
<Sider
@@ -104,7 +107,7 @@ const Layout: FC = () => {
104107
<SidebarLogo collapsed={collapsed} onClick={handleLogoClick} />
105108
{menuNode}
106109
{!collapsed && (
107-
<MiniCalendar selectedDate={selectedDate} onSelect={handleMiniCalendarSelect} />
110+
<MiniCalendar selectedDate={selectedDate} onSelect={handleMiniCalendarSelect} currentView={calendarView} weekStartDay={weekStartDay} />
108111
)}
109112
</Sider>
110113
)}

src/TimeHacker.UI/src/contexts/CalendarDateContext.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { createContext, useContext, useState } from 'react';
22
import type { FC, ReactNode } from 'react';
33

4+
export type CalendarView = 'month' | 'week' | 'day' | '3day';
5+
46
interface CalendarDateContextType {
57
selectedDate: Date;
68
setSelectedDate: (date: Date) => void;
9+
calendarView: CalendarView;
10+
setCalendarView: (view: CalendarView) => void;
711
}
812

913
const CalendarDateContext = createContext<CalendarDateContextType | null>(null);
1014

1115
export const CalendarDateProvider: FC<{ children: ReactNode }> = ({ children }) => {
1216
const [selectedDate, setSelectedDate] = useState(() => new Date());
17+
const [calendarView, setCalendarView] = useState<CalendarView>('week');
1318

1419
return (
15-
<CalendarDateContext.Provider value={{ selectedDate, setSelectedDate }}>
20+
<CalendarDateContext.Provider value={{ selectedDate, setSelectedDate, calendarView, setCalendarView }}>
1621
{children}
1722
</CalendarDateContext.Provider>
1823
);

src/TimeHacker.UI/src/pages/CalendarPage/index.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { CalendarEvent } from '../../utils/calendarUtils';
1717
import type { ScheduleEntityReturnModel } from '../../api/types';
1818
import { useTheme } from '../../contexts/ThemeContext';
1919
import { useCalendarDate } from '../../contexts/CalendarDateContext';
20+
import type { CalendarView } from '../../contexts/CalendarDateContext';
2021
import { useSettings } from '../../contexts/SettingsContext';
2122
import { useIsMobile } from '../../hooks/useIsMobile';
2223
import ThreeDayView from './ThreeDayView';
@@ -28,8 +29,6 @@ import EventDetailModal from './components/EventDetailModal';
2829

2930
dayjs.extend(updateLocale);
3031

31-
type ExtendedView = View | '3day';
32-
3332
const calendarViews = {
3433
month: true,
3534
week: true,
@@ -43,9 +42,7 @@ const CalendarPage: FC = () => {
4342
const { t, i18n } = useTranslation();
4443
const { timeDisplayFormat, weekStart: weekStartSetting } = useSettings();
4544
const initialViewSet = useRef(false);
46-
const { selectedDate, setSelectedDate } = useCalendarDate();
47-
48-
const [view, setView] = useState<ExtendedView>('week');
45+
const { selectedDate, setSelectedDate, calendarView, setCalendarView } = useCalendarDate();
4946
const [events, setEvents] = useState<CalendarEvent[]>([]);
5047
const [loading, setLoading] = useState(false);
5148
const [error, setError] = useState<string | null>(null);
@@ -65,7 +62,7 @@ const CalendarPage: FC = () => {
6562
useEffect(() => {
6663
if (!initialViewSet.current && screens.md !== undefined) {
6764
initialViewSet.current = true;
68-
setView(isMobile ? 'day' : 'week');
65+
setCalendarView(isMobile ? 'day' : 'week');
6966
}
7067
}, [isMobile, screens.md]);
7168

@@ -112,7 +109,7 @@ const CalendarPage: FC = () => {
112109
);
113110

114111
const getDatesForView = useCallback(
115-
(v: ExtendedView): Date[] => {
112+
(v: CalendarView): Date[] => {
116113
switch (v) {
117114
case 'month': return monthDays;
118115
case 'week': return weekDays;
@@ -154,14 +151,14 @@ const CalendarPage: FC = () => {
154151
);
155152

156153
useEffect(() => {
157-
fetchTasks(getDatesForView(view));
158-
}, [view, selectedDate, fetchTasks, getDatesForView]);
154+
fetchTasks(getDatesForView(calendarView));
155+
}, [calendarView, selectedDate, fetchTasks, getDatesForView]);
159156

160157
const refresh = useCallback(async () => {
161158
setLoading(true);
162159
setError(null);
163160
try {
164-
const dates = getDatesForView(view);
161+
const dates = getDatesForView(calendarView);
165162
await refreshTasksForDays(dates);
166163
await fetchTasks(dates);
167164
} catch (err: unknown) {
@@ -173,7 +170,7 @@ const CalendarPage: FC = () => {
173170
} finally {
174171
setLoading(false);
175172
}
176-
}, [view, getDatesForView, fetchTasks, t]);
173+
}, [calendarView, getDatesForView, fetchTasks, t]);
177174

178175
// --- Event handlers ---
179176

@@ -215,12 +212,12 @@ const CalendarPage: FC = () => {
215212
}
216213
setTaskModalOpen(false);
217214
notification.success({ message: t('tasks.success'), description: t('tasks.fixedTaskAdded') });
218-
await fetchTasks(getDatesForView(view));
215+
await fetchTasks(getDatesForView(calendarView));
219216
} catch {
220217
notification.error({ message: t('tasks.error'), description: t('tasks.fixedTaskSaveFailed') });
221218
}
222219
},
223-
[fetchTasks, getDatesForView, view, t]
220+
[fetchTasks, getDatesForView, calendarView, t]
224221
);
225222

226223
const handleSaveDynamic = useCallback(
@@ -229,12 +226,12 @@ const CalendarPage: FC = () => {
229226
await createDynamicTask(data);
230227
setTaskModalOpen(false);
231228
notification.success({ message: t('tasks.success'), description: t('tasks.dynamicTaskAdded') });
232-
await fetchTasks(getDatesForView(view));
229+
await fetchTasks(getDatesForView(calendarView));
233230
} catch {
234231
notification.error({ message: t('tasks.error'), description: t('tasks.dynamicTaskSaveFailed') });
235232
}
236233
},
237-
[fetchTasks, getDatesForView, view, t]
234+
[fetchTasks, getDatesForView, calendarView, t]
238235
);
239236

240237
const eventStyleGetter = useCallback(
@@ -304,13 +301,13 @@ const CalendarPage: FC = () => {
304301
startAccessor="start"
305302
endAccessor="end"
306303
style={{ flex: 1 }}
307-
view={view as View}
308-
onView={(v) => setView(v as ExtendedView)}
304+
view={calendarView as View}
305+
onView={(v) => setCalendarView(v as CalendarView)}
309306
date={selectedDate}
310307
onNavigate={setSelectedDate}
311308
views={calendarViews}
312309
onSelectEvent={handleSelectEvent}
313-
onDrillDown={(date) => { setSelectedDate(date); setView('day'); }}
310+
onDrillDown={(date) => { setSelectedDate(date); setCalendarView('day'); }}
314311
eventPropGetter={eventStyleGetter}
315312
culture={i18n.language?.startsWith('ru') ? 'ru' : 'en'}
316313
messages={calendarMessages as Record<string, string>}

0 commit comments

Comments
 (0)