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.

feat: support cloud-change based update prompt in build info

+242 -56
+18
app/api/build-info/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + export const revalidate = 0; 4 + 5 + export async function GET() { 6 + return NextResponse.json( 7 + { 8 + version: process.env.NEXT_PUBLIC_APP_VERSION ?? "unknown", 9 + commit: process.env.NEXT_PUBLIC_GIT_COMMIT ?? "unknown", 10 + deployedAt: process.env.NEXT_PUBLIC_BUILD_TIME ?? "", 11 + }, 12 + { 13 + headers: { 14 + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", 15 + }, 16 + }, 17 + ); 18 + }
+68 -56
app/sitemap.ts
··· 1 - import { MetadataRoute } from "next"; 2 1 import fs from "fs"; 3 2 import path from "path"; 3 + import { MetadataRoute } from "next"; 4 4 5 - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://calendar.xyehr.cn"; 5 + const DEFAULT_BASE_URL = "https://calendar.xyehr.cn"; 6 + const baseUrl = new URL( 7 + process.env.NEXT_PUBLIC_BASE_URL ?? DEFAULT_BASE_URL, 8 + ).toString(); 6 9 const appDirectory = path.join(process.cwd(), "app"); 10 + const fallbackDate = new Date(); 7 11 8 12 export const revalidate = 86400; 9 13 10 - export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 11 - const getFileModDate = (...segments: string[]) => { 12 - try { 13 - const stats = fs.statSync(path.join(appDirectory, ...segments)); 14 - return new Date(stats.mtime); 15 - } catch { 16 - return new Date(); 17 - } 18 - }; 14 + type SitemapRoute = { 15 + pathname: string; 16 + filePath: string[]; 17 + changeFrequency: NonNullable<MetadataRoute.Sitemap[number]["changeFrequency"]>; 18 + priority: number; 19 + }; 19 20 20 - const routes: MetadataRoute.Sitemap = [ 21 - { 22 - url: baseUrl, 23 - lastModified: getFileModDate("page.tsx"), 24 - changeFrequency: "daily", 25 - priority: 1.0, 26 - }, 27 - { 28 - url: `${baseUrl}/about`, 29 - lastModified: getFileModDate("about", "page.tsx"), 30 - changeFrequency: "monthly", 31 - priority: 0.8, 32 - }, 33 - { 34 - url: `${baseUrl}/privacy`, 35 - lastModified: getFileModDate("privacy", "page.tsx"), 36 - changeFrequency: "monthly", 37 - priority: 0.8, 38 - }, 39 - { 40 - url: `${baseUrl}/terms`, 41 - lastModified: getFileModDate("terms", "page.tsx"), 42 - changeFrequency: "monthly", 43 - priority: 0.8, 44 - }, 45 - { 46 - url: `${baseUrl}/app`, 47 - lastModified: getFileModDate("app", "page.tsx"), 48 - changeFrequency: "daliy", 49 - priority: 1.0, 50 - }, 51 - { 52 - url: `${baseUrl}/sign-in`, 53 - lastModified: getFileModDate("sign-in", "page.tsx"), 54 - changeFrequency: "monthly", 55 - priority: 0.7, 56 - }, 57 - { 58 - url: `${baseUrl}/sign-up`, 59 - lastModified: getFileModDate("sign-up", "page.tsx"), 60 - changeFrequency: "monthly", 61 - priority: 0.7, 62 - }, 63 - ]; 21 + const sitemapRoutes: SitemapRoute[] = [ 22 + { 23 + pathname: "/", 24 + filePath: ["page.tsx"], 25 + changeFrequency: "daily", 26 + priority: 1, 27 + }, 28 + { 29 + pathname: "/app", 30 + filePath: ["(app)", "app", "page.tsx"], 31 + changeFrequency: "daily", 32 + priority: 1, 33 + }, 34 + { 35 + pathname: "/privacy", 36 + filePath: ["(app)", "privacy", "page.tsx"], 37 + changeFrequency: "monthly", 38 + priority: 0.8, 39 + }, 40 + { 41 + pathname: "/terms", 42 + filePath: ["(app)", "terms", "page.tsx"], 43 + changeFrequency: "monthly", 44 + priority: 0.8, 45 + }, 46 + { 47 + pathname: "/sign-in", 48 + filePath: ["(auth)", "sign-in", "page.tsx"], 49 + changeFrequency: "monthly", 50 + priority: 0.7, 51 + }, 52 + { 53 + pathname: "/sign-up", 54 + filePath: ["(auth)", "sign-up", "page.tsx"], 55 + changeFrequency: "monthly", 56 + priority: 0.7, 57 + }, 58 + ]; 64 59 65 - return [...routes]; 60 + const getFileModDate = (...segments: string[]) => { 61 + try { 62 + const stats = fs.statSync(path.join(appDirectory, ...segments)); 63 + return new Date(stats.mtime); 64 + } catch { 65 + return fallbackDate; 66 + } 67 + }; 68 + 69 + const toAbsoluteUrl = (pathname: string) => new URL(pathname, baseUrl).toString(); 70 + 71 + export default function sitemap(): MetadataRoute.Sitemap { 72 + return sitemapRoutes.map((route) => ({ 73 + url: toAbsoluteUrl(route.pathname), 74 + lastModified: getFileModDate(...route.filePath), 75 + changeFrequency: route.changeFrequency, 76 + priority: route.priority, 77 + })); 66 78 }
+144
components/app/analytics/build-info-card.tsx
··· 1 1 "use client"; 2 2 3 3 import { translations, type Language } from "@/lib/i18n"; 4 + import { Button } from "@/components/ui/button"; 4 5 import { useEffect, useMemo, useState } from "react"; 5 6 6 7 const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "unknown"; ··· 36 37 37 38 export default function BuildInfoCard({ language }: BuildInfoCardProps) { 38 39 const [tick, setTick] = useState(0); 40 + const [updateRegistration, setUpdateRegistration] = 41 + useState<ServiceWorkerRegistration | null>(null); 42 + const [hasUpdate, setHasUpdate] = useState(false); 43 + const [isUpdating, setIsUpdating] = useState(false); 44 + const [hasCloudUpdate, setHasCloudUpdate] = useState(false); 39 45 40 46 useEffect(() => { 41 47 const timer = window.setInterval(() => { ··· 47 53 }; 48 54 }, []); 49 55 56 + useEffect(() => { 57 + if (!("serviceWorker" in navigator)) return; 58 + 59 + const checkForUpdate = (registration: ServiceWorkerRegistration) => { 60 + setUpdateRegistration(registration); 61 + setHasUpdate(Boolean(registration.waiting)); 62 + }; 63 + 64 + const handleControllerChange = () => { 65 + window.location.reload(); 66 + }; 67 + 68 + const initializeServiceWorkerUpdate = async () => { 69 + const registration = await navigator.serviceWorker.getRegistration(); 70 + if (!registration) return; 71 + 72 + checkForUpdate(registration); 73 + 74 + const handleUpdateFound = () => { 75 + const newWorker = registration.installing; 76 + if (!newWorker) return; 77 + 78 + newWorker.addEventListener("statechange", () => { 79 + if ( 80 + newWorker.state === "installed" && 81 + navigator.serviceWorker.controller 82 + ) { 83 + setHasUpdate(true); 84 + } 85 + }); 86 + }; 87 + 88 + registration.addEventListener("updatefound", handleUpdateFound); 89 + navigator.serviceWorker.addEventListener( 90 + "controllerchange", 91 + handleControllerChange, 92 + ); 93 + 94 + return () => { 95 + registration.removeEventListener("updatefound", handleUpdateFound); 96 + navigator.serviceWorker.removeEventListener( 97 + "controllerchange", 98 + handleControllerChange, 99 + ); 100 + }; 101 + }; 102 + 103 + const cleanupPromise = initializeServiceWorkerUpdate(); 104 + 105 + return () => { 106 + cleanupPromise.then((cleanup) => cleanup?.()).catch(() => undefined); 107 + }; 108 + }, []); 109 + 110 + useEffect(() => { 111 + const checkCloudBuildInfo = async () => { 112 + try { 113 + const response = await fetch(`/api/build-info?t=${Date.now()}`, { 114 + cache: "no-store", 115 + }); 116 + if (!response.ok) return; 117 + 118 + const nextBuild = (await response.json()) as { 119 + version?: string; 120 + commit?: string; 121 + deployedAt?: string; 122 + }; 123 + 124 + const changedByCommit = 125 + Boolean(nextBuild.commit) && nextBuild.commit !== COMMIT_HASH; 126 + const changedByDeployTime = 127 + Boolean(nextBuild.deployedAt) && nextBuild.deployedAt !== DEPLOYED_AT; 128 + const changedByVersion = 129 + Boolean(nextBuild.version) && nextBuild.version !== APP_VERSION; 130 + 131 + setHasCloudUpdate( 132 + changedByCommit || changedByDeployTime || changedByVersion, 133 + ); 134 + } catch { 135 + // Ignore transient network errors to keep the card non-blocking. 136 + } 137 + }; 138 + 139 + void checkCloudBuildInfo(); 140 + const timer = window.setInterval(() => { 141 + void checkCloudBuildInfo(); 142 + }, 120000); 143 + 144 + return () => { 145 + window.clearInterval(timer); 146 + }; 147 + }, []); 148 + 50 149 const t = translations[language]; 51 150 const deployedAgo = useMemo( 52 151 () => formatTimeAgo(language, DEPLOYED_AT), 53 152 [language, tick], 54 153 ); 55 154 155 + const handleUpdateStatic = async () => { 156 + if ((!updateRegistration && !hasCloudUpdate) || isUpdating) return; 157 + 158 + setIsUpdating(true); 159 + try { 160 + if (updateRegistration) { 161 + await updateRegistration.update(); 162 + } 163 + const waitingWorker = updateRegistration?.waiting; 164 + if (waitingWorker) { 165 + waitingWorker.postMessage({ type: "SKIP_WAITING" }); 166 + return; 167 + } 168 + 169 + if (hasCloudUpdate && "caches" in window) { 170 + const cacheKeys = await caches.keys(); 171 + await Promise.all( 172 + cacheKeys 173 + .filter((key) => key.startsWith("one-calendar-shell-")) 174 + .map((key) => caches.delete(key)), 175 + ); 176 + } 177 + 178 + const url = new URL(window.location.href); 179 + url.searchParams.set("updated", Date.now().toString()); 180 + window.location.assign(url.toString()); 181 + } finally { 182 + setIsUpdating(false); 183 + } 184 + }; 185 + 56 186 return ( 57 187 <div className="rounded-lg border p-4 space-y-3"> 58 188 <h2 className="text-base font-semibold">{t.buildInfoTitle}</h2> ··· 69 199 <span className="text-muted-foreground">{t.buildInfoDeployment}</span> 70 200 <span>{t.buildInfoDeployedAgo.replace("{time}", deployedAgo)}</span> 71 201 </div> 202 + {hasUpdate || hasCloudUpdate ? ( 203 + <div className="flex items-center justify-between gap-4"> 204 + <span className="text-muted-foreground"> 205 + {t.buildInfoUpdateAvailable} 206 + </span> 207 + <Button 208 + size="sm" 209 + onClick={handleUpdateStatic} 210 + disabled={isUpdating} 211 + > 212 + {isUpdating ? t.buildInfoUpdating : t.buildInfoUpdateButton} 213 + </Button> 214 + </div> 215 + ) : null} 72 216 </div> 73 217 </div> 74 218 );
+3
locales/en.json
··· 352 352 "buildInfoMinutes": "minutes", 353 353 "buildInfoHours": "hours", 354 354 "buildInfoDays": "days", 355 + "buildInfoUpdateAvailable": "Update available", 356 + "buildInfoUpdateButton": "Update now", 357 + "buildInfoUpdating": "Updating...", 355 358 "eventCreated": "Event created", 356 359 "eventUpdated": "Event updated", 357 360 "eventDeleted": "Event deleted",
+3
locales/zh-CN.json
··· 352 352 "buildInfoMinutes": "分钟", 353 353 "buildInfoHours": "小时", 354 354 "buildInfoDays": "天", 355 + "buildInfoUpdateAvailable": "发现新版本", 356 + "buildInfoUpdateButton": "立即更新", 357 + "buildInfoUpdating": "更新中...", 355 358 "eventCreated": "事件已创建", 356 359 "eventUpdated": "事件已更新", 357 360 "eventDeleted": "事件已删除",
+6
public/sw.js
··· 36 36 self.clients.claim(); 37 37 }); 38 38 39 + self.addEventListener("message", (event) => { 40 + if (event.data?.type === "SKIP_WAITING") { 41 + self.skipWaiting(); 42 + } 43 + }); 44 + 39 45 self.addEventListener("fetch", (event) => { 40 46 if (event.request.method !== "GET") return; 41 47