Skip to content

Commit f3c1c6f

Browse files
fusion94claude
andcommitted
Add calendar feature with mini widget and full page
Adds a community/personal event system with a sidebar mini calendar showing event dots, and a full calendar page with month grid, event chips, create/edit/delete dialogs, and clickable Zoom URLs. Includes server-side CRUD endpoints with auth and visibility filtering. Seeds 11 recurring Monthly UG Zoom Call events (2nd Tue, Feb–Dec 2026). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ca22be commit f3c1c6f

File tree

6 files changed

+638
-0
lines changed

6 files changed

+638
-0
lines changed

server/index.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,22 @@ db.exec(`
102102
)
103103
`);
104104

105+
// Create calendar_events table
106+
db.exec(`
107+
CREATE TABLE IF NOT EXISTS calendar_events (
108+
id INTEGER PRIMARY KEY AUTOINCREMENT,
109+
title TEXT NOT NULL,
110+
event_date TEXT NOT NULL,
111+
event_time TEXT,
112+
description TEXT DEFAULT '',
113+
location TEXT DEFAULT '',
114+
visibility TEXT NOT NULL DEFAULT 'personal',
115+
created_by TEXT NOT NULL,
116+
created_at INTEGER NOT NULL,
117+
updated_at INTEGER NOT NULL
118+
)
119+
`);
120+
105121
app.use(cors());
106122
app.use(express.json());
107123
app.use('/uploads', express.static(uploadsDir));
@@ -573,6 +589,106 @@ app.delete('/api/admin/invite/:token', requireAdmin, (req, res) => {
573589
res.json({ success: true });
574590
});
575591

592+
// Calendar Events: List events for a month
593+
app.get('/api/events', requireAuth, (req, res) => {
594+
const { month } = req.query; // YYYY-MM
595+
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
596+
return res.status(400).json({ error: 'month query param required in YYYY-MM format' });
597+
}
598+
const startDate = `${month}-01`;
599+
const endDate = `${month}-31`;
600+
const events = db.prepare(
601+
`SELECT * FROM calendar_events
602+
WHERE event_date >= ? AND event_date <= ?
603+
AND (visibility = 'community' OR created_by = ?)
604+
ORDER BY event_date, event_time`
605+
).all(startDate, endDate, req.user.username);
606+
res.json(events);
607+
});
608+
609+
// Calendar Events: Get single event
610+
app.get('/api/events/:id', requireAuth, (req, res) => {
611+
const event = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(req.params.id);
612+
if (!event) {
613+
return res.status(404).json({ error: 'Event not found' });
614+
}
615+
if (event.visibility === 'personal' && event.created_by !== req.user.username) {
616+
return res.status(403).json({ error: 'Access denied' });
617+
}
618+
res.json(event);
619+
});
620+
621+
// Calendar Events: Create
622+
app.post('/api/events', requireAuth, (req, res) => {
623+
const { title, event_date, event_time, description, location, visibility } = req.body;
624+
if (!title || !event_date) {
625+
return res.status(400).json({ error: 'title and event_date are required' });
626+
}
627+
if (visibility && !['community', 'personal'].includes(visibility)) {
628+
return res.status(400).json({ error: 'visibility must be community or personal' });
629+
}
630+
const now = Date.now();
631+
const result = db.prepare(
632+
`INSERT INTO calendar_events (title, event_date, event_time, description, location, visibility, created_by, created_at, updated_at)
633+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
634+
).run(
635+
title,
636+
event_date,
637+
event_time || null,
638+
description || '',
639+
location || '',
640+
visibility || 'personal',
641+
req.user.username,
642+
now,
643+
now
644+
);
645+
const event = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(result.lastInsertRowid);
646+
res.json(event);
647+
});
648+
649+
// Calendar Events: Update
650+
app.put('/api/events/:id', requireAuth, (req, res) => {
651+
const event = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(req.params.id);
652+
if (!event) {
653+
return res.status(404).json({ error: 'Event not found' });
654+
}
655+
if (event.created_by !== req.user.username) {
656+
return res.status(403).json({ error: 'Only the event creator can edit' });
657+
}
658+
const { title, event_date, event_time, description, location, visibility } = req.body;
659+
if (visibility && !['community', 'personal'].includes(visibility)) {
660+
return res.status(400).json({ error: 'visibility must be community or personal' });
661+
}
662+
db.prepare(
663+
`UPDATE calendar_events SET title = ?, event_date = ?, event_time = ?, description = ?, location = ?, visibility = ?, updated_at = ?
664+
WHERE id = ?`
665+
).run(
666+
title !== undefined ? title : event.title,
667+
event_date !== undefined ? event_date : event.event_date,
668+
event_time !== undefined ? (event_time || null) : event.event_time,
669+
description !== undefined ? description : event.description,
670+
location !== undefined ? location : event.location,
671+
visibility !== undefined ? visibility : event.visibility,
672+
Date.now(),
673+
req.params.id
674+
);
675+
const updated = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(req.params.id);
676+
res.json(updated);
677+
});
678+
679+
// Calendar Events: Delete
680+
app.delete('/api/events/:id', requireAuth, (req, res) => {
681+
const event = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(req.params.id);
682+
if (!event) {
683+
return res.status(404).json({ error: 'Event not found' });
684+
}
685+
if (event.created_by !== req.user.username && !req.user.isAdmin) {
686+
return res.status(403).json({ error: 'Only the creator or admin can delete' });
687+
}
688+
db.prepare('DELETE FROM calendar_events WHERE id = ?').run(req.params.id);
689+
res.json({ success: true });
690+
});
691+
576692
app.listen(PORT, () => {
577693
console.log(`SH Underground API running on port ${PORT}`);
578694
});

src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Register from './pages/Register';
77
import Profile from './pages/Profile';
88
import MapView from './pages/MapView';
99
import AdminDashboard from './pages/AdminDashboard';
10+
import CalendarPage from './pages/CalendarPage';
1011

1112
function App() {
1213
return (
@@ -25,6 +26,7 @@ function App() {
2526
<Route index element={<Navigate to="/app/profile" replace />} />
2627
<Route path="profile" element={<Profile />} />
2728
<Route path="map" element={<MapView />} />
29+
<Route path="calendar" element={<CalendarPage />} />
2830
<Route path="admin" element={<AdminDashboard />} />
2931
</Route>
3032
<Route path="*" element={<Navigate to="/" replace />} />

src/api.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,64 @@ export const api = {
210210
}
211211
return res.json();
212212
},
213+
214+
async getEvents(month) {
215+
const res = await fetch(`${API_BASE}/events?month=${month}`, {
216+
headers: getAuthHeaders(),
217+
});
218+
if (!res.ok) {
219+
const data = await res.json();
220+
throw new Error(data.error || 'Failed to get events');
221+
}
222+
return res.json();
223+
},
224+
225+
async getEvent(id) {
226+
const res = await fetch(`${API_BASE}/events/${id}`, {
227+
headers: getAuthHeaders(),
228+
});
229+
if (!res.ok) {
230+
const data = await res.json();
231+
throw new Error(data.error || 'Failed to get event');
232+
}
233+
return res.json();
234+
},
235+
236+
async createEvent(eventData) {
237+
const res = await fetch(`${API_BASE}/events`, {
238+
method: 'POST',
239+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
240+
body: JSON.stringify(eventData),
241+
});
242+
if (!res.ok) {
243+
const data = await res.json();
244+
throw new Error(data.error || 'Failed to create event');
245+
}
246+
return res.json();
247+
},
248+
249+
async updateEvent(id, eventData) {
250+
const res = await fetch(`${API_BASE}/events/${id}`, {
251+
method: 'PUT',
252+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
253+
body: JSON.stringify(eventData),
254+
});
255+
if (!res.ok) {
256+
const data = await res.json();
257+
throw new Error(data.error || 'Failed to update event');
258+
}
259+
return res.json();
260+
},
261+
262+
async deleteEvent(id) {
263+
const res = await fetch(`${API_BASE}/events/${id}`, {
264+
method: 'DELETE',
265+
headers: getAuthHeaders(),
266+
});
267+
if (!res.ok) {
268+
const data = await res.json();
269+
throw new Error(data.error || 'Failed to delete event');
270+
}
271+
return res.json();
272+
},
213273
};

src/components/Layout.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
33
import {
44
AppBar,
55
Box,
6+
Divider,
67
Drawer,
78
IconButton,
89
List,
@@ -19,9 +20,11 @@ import {
1920
import MenuIcon from '@mui/icons-material/Menu';
2021
import PersonIcon from '@mui/icons-material/Person';
2122
import MapIcon from '@mui/icons-material/Map';
23+
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
2224
import LogoutIcon from '@mui/icons-material/Logout';
2325
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
2426
import { useAuth } from '../contexts/AuthContext';
27+
import MiniCalendar from './MiniCalendar';
2528

2629
const drawerWidth = 240;
2730

@@ -32,6 +35,7 @@ function Layout() {
3235
const menuItems = [
3336
{ text: 'Profile', icon: <PersonIcon />, path: '/app/profile' },
3437
{ text: 'Map View', icon: <MapIcon />, path: '/app/map' },
38+
{ text: 'Calendar', icon: <CalendarMonthIcon />, path: '/app/calendar' },
3539
...(isAdmin ? [{ text: 'Admin', icon: <AdminPanelSettingsIcon />, path: '/app/admin' }] : []),
3640
];
3741
const navigate = useNavigate();
@@ -81,6 +85,8 @@ function Layout() {
8185
</ListItem>
8286
))}
8387
</List>
88+
<Divider />
89+
<MiniCalendar />
8490
</Box>
8591
);
8692

src/components/MiniCalendar.jsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useState, useEffect } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { Box, IconButton, Typography } from '@mui/material';
4+
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
5+
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
6+
import { api } from '../api';
7+
8+
const DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
9+
10+
function MiniCalendar() {
11+
const navigate = useNavigate();
12+
const today = new Date();
13+
const [year, setYear] = useState(today.getFullYear());
14+
const [month, setMonth] = useState(today.getMonth());
15+
const [eventDays, setEventDays] = useState(new Set());
16+
17+
const monthStr = `${year}-${String(month + 1).padStart(2, '0')}`;
18+
19+
useEffect(() => {
20+
api.getEvents(monthStr).then((events) => {
21+
const days = new Set(events.map((e) => parseInt(e.event_date.split('-')[2], 10)));
22+
setEventDays(days);
23+
}).catch(() => {});
24+
}, [monthStr]);
25+
26+
const prevMonth = () => {
27+
if (month === 0) { setYear(year - 1); setMonth(11); }
28+
else setMonth(month - 1);
29+
};
30+
const nextMonth = () => {
31+
if (month === 11) { setYear(year + 1); setMonth(0); }
32+
else setMonth(month + 1);
33+
};
34+
35+
const firstDay = new Date(year, month, 1).getDay();
36+
const daysInMonth = new Date(year, month + 1, 0).getDate();
37+
const cells = [];
38+
for (let i = 0; i < firstDay; i++) cells.push(null);
39+
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
40+
41+
const isToday = (d) =>
42+
d && year === today.getFullYear() && month === today.getMonth() && d === today.getDate();
43+
44+
const handleClick = (d) => {
45+
if (!d) return;
46+
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
47+
navigate(`/app/calendar?date=${dateStr}`);
48+
};
49+
50+
const monthName = new Date(year, month).toLocaleString('default', { month: 'long' });
51+
52+
return (
53+
<Box sx={{ px: 1.5, py: 1 }}>
54+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
55+
<IconButton size="small" onClick={prevMonth}><ChevronLeftIcon fontSize="small" /></IconButton>
56+
<Typography variant="caption" sx={{ fontWeight: 'bold', fontSize: '0.75rem' }}>
57+
{monthName} {year}
58+
</Typography>
59+
<IconButton size="small" onClick={nextMonth}><ChevronRightIcon fontSize="small" /></IconButton>
60+
</Box>
61+
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px', textAlign: 'center' }}>
62+
{DAYS.map((d, i) => (
63+
<Typography key={i} variant="caption" sx={{ fontSize: '0.65rem', color: 'text.secondary', lineHeight: '20px' }}>
64+
{d}
65+
</Typography>
66+
))}
67+
{cells.map((d, i) => (
68+
<Box
69+
key={i}
70+
onClick={() => handleClick(d)}
71+
sx={{
72+
width: 28,
73+
height: 28,
74+
display: 'flex',
75+
flexDirection: 'column',
76+
alignItems: 'center',
77+
justifyContent: 'center',
78+
cursor: d ? 'pointer' : 'default',
79+
borderRadius: '50%',
80+
border: isToday(d) ? '2px solid' : '2px solid transparent',
81+
borderColor: isToday(d) ? 'primary.main' : 'transparent',
82+
position: 'relative',
83+
'&:hover': d ? { bgcolor: 'action.hover' } : {},
84+
}}
85+
>
86+
<Typography variant="caption" sx={{ fontSize: '0.7rem', lineHeight: 1 }}>
87+
{d || ''}
88+
</Typography>
89+
{d && eventDays.has(d) && (
90+
<Box sx={{
91+
width: 4, height: 4, borderRadius: '50%',
92+
bgcolor: 'primary.main', position: 'absolute', bottom: 1,
93+
}} />
94+
)}
95+
</Box>
96+
))}
97+
</Box>
98+
</Box>
99+
);
100+
}
101+
102+
export default MiniCalendar;

0 commit comments

Comments
 (0)