One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #179 from EvanTechDev/feature/fix-display-bugs-in-monthly-view

Normalize all-day event handling, memoize year/month views, and UI/checkbox fixes

authored by

Evan Huang and committed by
GitHub
b8c8f607 9a9d9115

+78 -44
+1 -1
components/app/calendar.tsx
··· 189 189 if (view !== defaultView) { 190 190 setView(defaultView as ViewType); 191 191 } 192 - }, []); 192 + }, [defaultView]); 193 193 194 194 useEffect(() => { 195 195 const refreshBackupState = () => {
+19 -9
components/app/event/event-dialog.tsx
··· 18 18 PopoverContent, 19 19 PopoverTrigger, 20 20 } from "@/components/ui/popover"; 21 - import { format, parse, isValid, set, getHours, getMinutes } from "date-fns"; 21 + import { addDays, format, parse, isValid, set, getHours, getMinutes } from "date-fns"; 22 22 import { ArrowRight, Calendar as CalendarIcon, Clock } from "lucide-react"; 23 23 import { isZhLanguage, translations, type Language } from "@/lib/i18n"; 24 24 import { useCalendar } from "@/components/providers/calendar-context"; ··· 452 452 453 453 const fullStartDate = getFullStartDate(); 454 454 const fullEndDate = getFullEndDate(); 455 + const normalizedStartDate = isAllDay 456 + ? set(new Date(startDate), { 457 + hours: 0, 458 + minutes: 0, 459 + seconds: 0, 460 + milliseconds: 0, 461 + }) 462 + : fullStartDate; 463 + const normalizedEndDate = isAllDay 464 + ? set(addDays(new Date(endDate), 1), { 465 + hours: 0, 466 + minutes: 0, 467 + seconds: 0, 468 + milliseconds: 0, 469 + }) 470 + : fullEndDate; 455 471 456 472 const eventData: CalendarEvent = { 457 473 id: ··· 459 475 Date.now().toString() + Math.random().toString(36).substring(2, 9), 460 476 title: title.trim() || t.untitledInParentheses, 461 477 isAllDay, 462 - startDate: fullStartDate, 463 - endDate: fullEndDate, 478 + startDate: normalizedStartDate, 479 + endDate: normalizedEndDate, 464 480 recurrence: "none", 465 481 location, 466 482 participants: participants ··· 660 676 setIsAllDay(isChecked); 661 677 662 678 if (isChecked) { 663 - const startOfDay = new Date(startDate); 664 - startOfDay.setHours(0, 0, 0, 0); 665 - 666 - const endOfDay = new Date(startDate); 667 - endOfDay.setHours(23, 59, 59, 999); 668 - 669 679 setStartTime({ 670 680 hours: "00", 671 681 minutes: "00",
+8 -11
components/app/event/event-preview.tsx
··· 34 34 import { Input } from "@/components/ui/input"; 35 35 import { Label } from "@/components/ui/label"; 36 36 import { Spinner } from "@/components/ui/spinner"; 37 + import { Checkbox } from "@/components/ui/checkbox"; 37 38 import { toast } from "sonner"; 38 39 import QRCodeStyling from "qr-code-styling"; 39 40 import { useUser } from "@clerk/nextjs"; ··· 624 625 </DialogHeader> 625 626 626 627 {!shareLink ? ( 627 - <div className="space-y-4 py-2"> 628 + <div className="space-y-4 pt-2"> 628 629 <div className="space-y-2"> 629 630 <Label htmlFor="shared-by">{t.share}</Label> 630 631 <p className="text-sm text-muted-foreground"> ··· 635 636 <div className="space-y-3"> 636 637 <div className="flex items-center justify-between"> 637 638 <Label htmlFor="enable-password">{t.shareEnablePasswordProtection}</Label> 638 - <input 639 + <Checkbox 639 640 id="enable-password" 640 - type="checkbox" 641 641 checked={passwordEnabled} 642 - onChange={(e) => { 643 - const v = e.target.checked; 642 + onCheckedChange={(checked) => { 643 + const v = checked === true; 644 644 setPasswordEnabled(v); 645 645 if (!v) { 646 646 setSharePassword(""); 647 647 setBurnAfterRead(false); 648 648 } 649 649 }} 650 - className="h-4 w-4" 651 650 /> 652 651 </div> 653 652 ··· 667 666 668 667 <div className="flex items-center justify-between pt-2"> 669 668 <Label htmlFor="burn-after-read">{t.shareBurnAfterRead}</Label> 670 - <input 669 + <Checkbox 671 670 id="burn-after-read" 672 - type="checkbox" 673 671 checked={burnAfterRead} 674 - onChange={(e) => setBurnAfterRead(e.target.checked)} 675 - className="h-4 w-4" 672 + onCheckedChange={(checked) => setBurnAfterRead(checked === true)} 676 673 /> 677 674 </div> 678 675 <p className="text-xs text-muted-foreground"> ··· 711 708 </DialogFooter> 712 709 </div> 713 710 ) : ( 714 - <div className="space-y-4 py-2"> 711 + <div className="space-y-4 pt-2"> 715 712 <div className="space-y-4"> 716 713 <div className="space-y-2"> 717 714 <Label htmlFor="share-link">{t.shareLink}</Label>
+6 -3
components/app/profile/shared-event.tsx
··· 284 284 /> 285 285 </div> 286 286 </div> 287 - <Loader2 className="h-16 w-16 text-blue-500 animate-spin" /> 287 + <Loader2 className="h-16 w-16 animate-spin text-black dark:text-white" /> 288 288 <p className="mt-6 text-lg font-medium text-gray-600 dark:text-gray-300"> 289 289 {isZh ? "ๅŠ ่ฝฝไธญ..." : "Loading..."} 290 290 </p> ··· 453 453 const startDate = new Date(event.startDate); 454 454 const endDate = new Date(event.endDate); 455 455 const durationMs = endDate.getTime() - startDate.getTime(); 456 - const durationHours = Math.floor(durationMs / (1000 * 60 * 60)); 457 - const durationMinutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); 456 + const normalizedDurationMs = event.isAllDay 457 + ? Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24))) * 24 * 60 * 60 * 1000 458 + : durationMs; 459 + const durationHours = Math.floor(normalizedDurationMs / (1000 * 60 * 60)); 460 + const durationMinutes = Math.floor((normalizedDurationMs % (1000 * 60 * 60)) / (1000 * 60)); 458 461 const durationText = 459 462 isZh 460 463 ? `${durationHours > 0 ? `${durationHours}ๅฐๆ—ถ` : ""}${durationMinutes > 0 ? ` ${durationMinutes}ๅˆ†้’Ÿ` : ""}`
+6
components/app/profile/user-profile-button.tsx
··· 403 403 await setEncryptionPassword(password) 404 404 const restoredEvents = await readEncryptedLocalStorage("calendar-events", []) 405 405 const restoredCalendars = await readEncryptedLocalStorage("calendar-categories", []) 406 + const restoredLanguage = await readEncryptedLocalStorage<string | null>("preferred-language", null) 406 407 setEvents(restoredEvents) 407 408 setCalendars(restoredCalendars) 409 + if (restoredLanguage) { 410 + window.dispatchEvent( 411 + new CustomEvent("languagechange", { detail: { language: restoredLanguage } }), 412 + ) 413 + } 408 414 } catch {} 409 415 410 416 keyRef.current = password
+1 -1
components/app/views/month-view.tsx
··· 90 90 className={cn( 91 91 "font-medium text-sm", 92 92 isSameMonth(day, date) ? "" : "text-gray-400", 93 - isSameDay(day, today) 93 + isSameMonth(day, date) && isSameDay(day, today) 94 94 ? "text-[#0066FF] font-bold green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]" 95 95 : "", 96 96 )}
+28 -17
components/app/views/year-view.tsx
··· 62 62 const [openDayKey, setOpenDayKey] = useState<string | null>(null) 63 63 const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark") 64 64 65 - const weekdayLabels = [...t.weekdays.slice(firstDayOfWeek), ...t.weekdays.slice(0, firstDayOfWeek)] 65 + const weekdayLabels = useMemo( 66 + () => [...t.weekdays.slice(firstDayOfWeek), ...t.weekdays.slice(0, firstDayOfWeek)], 67 + [firstDayOfWeek, t.weekdays], 68 + ) 66 69 67 70 const eventsByDayKey = useMemo(() => { 68 71 const grouped = new Map<string, CalendarEvent[]>() ··· 81 84 return grouped 82 85 }, [events]) 83 86 84 - const months = Array.from({ length: 12 }, (_, monthIndex) => { 85 - const monthStart = new Date(currentYear, monthIndex, 1) 86 - const monthEnd = endOfMonth(monthStart) 87 - const gridStart = startOfWeek(monthStart, { weekStartsOn: firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6 }) 88 - const monthDays = eachDayOfInterval({ start: gridStart, end: monthEnd }) 87 + const months = useMemo( 88 + () => 89 + Array.from({ length: 12 }, (_, monthIndex) => { 90 + const monthStart = new Date(currentYear, monthIndex, 1) 91 + const monthEnd = endOfMonth(monthStart) 92 + const gridStart = startOfWeek(monthStart, { 93 + weekStartsOn: firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6, 94 + }) 95 + const monthDays = eachDayOfInterval({ start: gridStart, end: monthEnd }) 89 96 90 - while (monthDays.length < 42) { 91 - const lastDay = monthDays[monthDays.length - 1] 92 - monthDays.push(new Date(lastDay.getFullYear(), lastDay.getMonth(), lastDay.getDate() + 1)) 93 - } 97 + while (monthDays.length < 42) { 98 + const lastDay = monthDays[monthDays.length - 1] 99 + monthDays.push( 100 + new Date(lastDay.getFullYear(), lastDay.getMonth(), lastDay.getDate() + 1), 101 + ) 102 + } 94 103 95 - return { 96 - monthIndex, 97 - label: t.months[monthIndex] ?? format(monthStart, "LLLL"), 98 - days: monthDays, 99 - } 100 - }) 104 + return { 105 + monthIndex, 106 + label: t.months[monthIndex] ?? format(monthStart, "LLLL"), 107 + days: monthDays, 108 + } 109 + }), 110 + [currentYear, firstDayOfWeek, t.months], 111 + ) 101 112 102 113 return ( 103 114 <div className="p-3 md:p-4"> ··· 139 150 "mx-auto flex h-6 w-6 items-center justify-center rounded-full text-xs transition-colors hover:bg-accent", 140 151 !isCurrentMonth && "text-muted-foreground", 141 152 dayEvents.length > 0 && "font-semibold", 142 - isToday && 153 + isToday && isCurrentMonth && 143 154 "bg-[#0066FF] text-white green:bg-[#24a854] orange:bg-[#e26912] azalea:bg-[#CD2F7B]", 144 155 )} 145 156 >
+9 -2
lib/time-analytics.ts
··· 53 53 events.forEach((event) => { 54 54 const startDate = new Date(event.startDate); 55 55 const endDate = new Date(event.endDate); 56 - const durationHours = 57 - (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); 56 + const durationMs = endDate.getTime() - startDate.getTime(); 57 + const normalizedDurationMs = event.isAllDay 58 + ? Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24))) * 59 + 24 * 60 + 60 * 61 + 60 * 62 + 1000 63 + : durationMs; 64 + const durationHours = normalizedDurationMs / (1000 * 60 * 60); 58 65 59 66 result.totalHours += durationHours; 60 67