feat: add Notion provider integration + Apple Reminders deprecation note

- Notion OAuth integration: start/callback routes, calendar-events dispatch,
  CRUD operations (create/update/delete via Notion API)
- New notion-calendar.ts provider library with database discovery
- Onboarding wizard: expanded to 6 steps (header/tasks/display design),
  improved preview fidelity, universal dummy content, Notion in connect step
- Apple Calendar: label changed to "events only", added warning that
  Reminders are unsupported since iOS 13 (no CalDAV/API from Apple)
- Fixed 12h time format on now-line, wizard settings apply on completion

v1.57.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-22 21:02:25 +01:00
parent 4ac73d3e03
commit ee4b540558
11 changed files with 1464 additions and 325 deletions

14
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.36.1", "version": "1.56.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.36.1", "version": "1.56.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
@ -15,6 +15,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0", "@fortawesome/react-fontawesome": "^3.2.0",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@notionhq/client": "^5.14.0",
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"@tiptap/extension-link": "^3.20.0", "@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0", "@tiptap/extension-placeholder": "^3.20.0",
@ -2269,6 +2270,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@notionhq/client": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/@notionhq/client/-/client-5.14.0.tgz",
"integrity": "sha512-9bbH7/9M6D9YlHMYCZ1aAFxRCWiKRBpP/XOnAHFtBCFDf00PPhpWRSsGE1FfmjYCNW2BFRj19WshJFH5IFfNvg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@panva/hkdf": { "node_modules/@panva/hkdf": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.56.0", "version": "1.57.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@ -27,6 +27,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0", "@fortawesome/react-fontawesome": "^3.2.0",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@notionhq/client": "^5.14.0",
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"@tiptap/extension-link": "^3.20.0", "@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0", "@tiptap/extension-placeholder": "^3.20.0",

View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { getUserDatabases } from '@/lib/notion-calendar';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
if (!code) {
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
}
const userId = (session?.user as any)?.id || state;
if (!userId) {
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', appBaseUrl));
}
// Exchange code for tokens
const clientId = process.env.NOTION_CLIENT_ID || '';
const clientSecret = process.env.NOTION_CLIENT_SECRET || '';
const redirectUri = process.env.NOTION_REDIRECT_URI
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error('Notion token exchange failed:', errorText);
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
}
const tokens = await tokenResponse.json();
const accessToken = tokens.access_token;
const refreshToken = tokens.refresh_token || null;
// Fetch databases shared with the integration
const databases = await getUserDatabases(accessToken);
// Map to calendar-style format for consistency
const calendars = databases.map(db => ({
id: db.id,
title: db.title,
selected: true,
dateProperty: db.dateProperty,
backgroundColor: '#000000',
}));
// Check for existing connection
const existingConnection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'notion' },
});
if (existingConnection) {
// Merge selection state
let finalCalendars = calendars;
if (existingConnection.calendars && Array.isArray(existingConnection.calendars)) {
const existingList = existingConnection.calendars as any[];
finalCalendars = calendars.map(remote => {
const match = existingList.find((e: any) => e.id === remote.id);
return {
...remote,
selected: match ? match.selected : true,
dateProperty: remote.dateProperty || match?.dateProperty,
};
});
}
await prisma.calendarConnection.update({
where: { id: existingConnection.id },
data: {
accessToken,
refreshToken: refreshToken || existingConnection.refreshToken,
calendars: finalCalendars,
updatedAt: new Date(),
},
});
} else {
await prisma.calendarConnection.create({
data: {
userId: user.id,
provider: 'notion',
accessToken,
refreshToken,
calendars,
},
});
}
return NextResponse.redirect(new URL('/tasks?calendar=connected&openSettings=calendars', appBaseUrl));
} catch (error) {
console.error('Notion OAuth error:', error);
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
}
}

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as any)?.id;
if (!userId) {
const baseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/auth/login', baseUrl));
}
const clientId = process.env.NOTION_CLIENT_ID;
if (!clientId) {
return NextResponse.json(
{ error: 'Notion OAuth not configured. Please set NOTION_CLIENT_ID and NOTION_CLIENT_SECRET.' },
{ status: 500 }
);
}
const redirectUri = process.env.NOTION_REDIRECT_URI
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
const authUrl = new URL('https://api.notion.com/v1/oauth/authorize');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('owner', 'user');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', userId);
return NextResponse.redirect(authUrl.toString());
} catch (error) {
console.error('Error initiating Notion OAuth:', error);
return NextResponse.json(
{ error: 'Failed to initiate Notion connection' },
{ status: 500 }
);
}
}

View File

@ -4908,9 +4908,26 @@ h3 {
@media (max-width: 480px) { @media (max-width: 480px) {
.onboarding-card { .onboarding-card {
width: 100%; width: 100%;
max-width: 100%; max-width: 100% !important;
border-radius: 0; border-radius: 0;
max-height: 100vh; max-height: 100vh;
height: 100vh; height: 100vh;
} }
} }
@media (max-width: 768px) {
.onboarding-card {
max-width: 95% !important;
}
.onboarding-wide-step {
flex-direction: column !important;
}
.onboarding-wide-step .onboarding-settings-panel {
width: 100% !important;
max-height: none !important;
overflow-y: visible !important;
}
.onboarding-wide-step .onboarding-preview-panel {
min-height: 300px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ import CalendarEventModal from "./CalendarEventModal";
import TaskRecurrenceModal from "./RecurrenceModal"; import TaskRecurrenceModal from "./RecurrenceModal";
import { GridTaskBlock } from "./GridTaskBlock"; import { GridTaskBlock } from "./GridTaskBlock";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons"; import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
import { import {
faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar, faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar,
faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop, faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop,
@ -161,7 +161,7 @@ interface CalendarEvent {
title: string; title: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
source: "google" | "apple" | "outlook" | "synology"; source: "google" | "apple" | "outlook" | "synology" | "notion";
calendarId?: string; calendarId?: string;
calendarTitle?: string; calendarTitle?: string;
calendarColor?: string; calendarColor?: string;
@ -290,7 +290,9 @@ const translations: Record<string, any> = {
connectMore: "Connect More", connectMore: "Connect More",
connectGoogle: "Connect Google Calendar", connectGoogle: "Connect Google Calendar",
connectApple: "Connect Apple Calendar", connectApple: "Connect Apple Calendar",
appleRemindersNote: "Apple Reminders are not supported. Since iOS 13 / macOS Catalina, Apple no longer provides a CalDAV or public API for Reminders. Only calendar events can be synced.",
connectSynology: "Connect Synology", connectSynology: "Connect Synology",
connectNotion: "Connect Notion",
noCalendars: "No calendars connected yet.", noCalendars: "No calendars connected yet.",
dataPrivacy: "Data & Privacy", dataPrivacy: "Data & Privacy",
downloadData: "Download My Data", downloadData: "Download My Data",
@ -381,6 +383,7 @@ const translations: Record<string, any> = {
styling: "Styling", styling: "Styling",
motivation: "Motivation", motivation: "Motivation",
about: "About", about: "About",
setupAssistant: "Run Setup Assistant",
weekStartLabel: "Start week on", weekStartLabel: "Start week on",
startViewLabel: "Start view on", startViewLabel: "Start view on",
monday: "Monday", monday: "Monday",
@ -504,6 +507,8 @@ const translations: Record<string, any> = {
connectMore: "Mehr verbinden", connectMore: "Mehr verbinden",
connectGoogle: "Google Kalender verbinden", connectGoogle: "Google Kalender verbinden",
connectApple: "Apple Kalender verbinden", connectApple: "Apple Kalender verbinden",
appleRemindersNote: "Apple Erinnerungen werden nicht unterstützt. Seit iOS 13 / macOS Catalina bietet Apple keine CalDAV- oder öffentliche API mehr für Erinnerungen an. Nur Kalender-Ereignisse können synchronisiert werden.",
connectNotion: "Notion verbinden",
noCalendars: "Keine Kalender verbunden.", noCalendars: "Keine Kalender verbunden.",
dataPrivacy: "Daten & Datenschutz", dataPrivacy: "Daten & Datenschutz",
downloadData: "Meine Daten herunterladen", downloadData: "Meine Daten herunterladen",
@ -594,6 +599,7 @@ const translations: Record<string, any> = {
styling: "Design", styling: "Design",
motivation: "Motivation", motivation: "Motivation",
about: "Über", about: "Über",
setupAssistant: "Einrichtungsassistent starten",
weekStartLabel: "Woche beginnt am", weekStartLabel: "Woche beginnt am",
startViewLabel: "Ansicht beginnt mit", startViewLabel: "Ansicht beginnt mit",
monday: "Montag", monday: "Montag",
@ -715,7 +721,9 @@ const translations: Record<string, any> = {
connectMore: "En connecter d'autres", connectMore: "En connecter d'autres",
connectGoogle: "Connecter Google Agenda", connectGoogle: "Connecter Google Agenda",
connectApple: "Connecter le calendrier Apple", connectApple: "Connecter le calendrier Apple",
appleRemindersNote: "Les rappels Apple ne sont pas pris en charge. Depuis iOS 13 / macOS Catalina, Apple ne fournit plus de CalDAV ni d'API publique pour les rappels. Seuls les événements de calendrier peuvent être synchronisés.",
connectSynology: "Connecter Synology", connectSynology: "Connecter Synology",
connectNotion: "Connecter Notion",
noCalendars: "Aucun calendrier connecté.", noCalendars: "Aucun calendrier connecté.",
dataPrivacy: "Données et confidentialité", dataPrivacy: "Données et confidentialité",
downloadData: "Télécharger mes données", downloadData: "Télécharger mes données",
@ -806,6 +814,7 @@ const translations: Record<string, any> = {
styling: "Style", styling: "Style",
motivation: "Motivation", motivation: "Motivation",
about: "À propos", about: "À propos",
setupAssistant: "Lancer l'assistant de configuration",
weekStartLabel: "La semaine commence le", weekStartLabel: "La semaine commence le",
startViewLabel: "Vue commence par", startViewLabel: "Vue commence par",
monday: "Lundi", monday: "Lundi",
@ -927,7 +936,9 @@ const translations: Record<string, any> = {
connectMore: "Conectar más", connectMore: "Conectar más",
connectGoogle: "Conectar Google Calendar", connectGoogle: "Conectar Google Calendar",
connectApple: "Conectar calendario de Apple", connectApple: "Conectar calendario de Apple",
appleRemindersNote: "Los recordatorios de Apple no son compatibles. Desde iOS 13 / macOS Catalina, Apple ya no ofrece CalDAV ni una API pública para recordatorios. Solo se pueden sincronizar eventos del calendario.",
connectSynology: "Conectar Synology", connectSynology: "Conectar Synology",
connectNotion: "Conectar Notion",
noCalendars: "No hay calendarios conectados.", noCalendars: "No hay calendarios conectados.",
dataPrivacy: "Datos y privacidad", dataPrivacy: "Datos y privacidad",
downloadData: "Descargar mis datos", downloadData: "Descargar mis datos",
@ -1018,6 +1029,7 @@ const translations: Record<string, any> = {
styling: "Estilo", styling: "Estilo",
motivation: "Motivación", motivation: "Motivación",
about: "Acerca de", about: "Acerca de",
setupAssistant: "Iniciar asistente de configuración",
weekStartLabel: "La semana empieza el", weekStartLabel: "La semana empieza el",
startViewLabel: "Vista empieza con", startViewLabel: "Vista empieza con",
monday: "Lunes", monday: "Lunes",
@ -1139,7 +1151,9 @@ const translations: Record<string, any> = {
connectMore: "Collega altri", connectMore: "Collega altri",
connectGoogle: "Collega Google Calendar", connectGoogle: "Collega Google Calendar",
connectApple: "Collega il calendario Apple", connectApple: "Collega il calendario Apple",
appleRemindersNote: "I promemoria Apple non sono supportati. Da iOS 13 / macOS Catalina, Apple non fornisce più CalDAV o un'API pubblica per i promemoria. Solo gli eventi del calendario possono essere sincronizzati.",
connectSynology: "Collega Synology", connectSynology: "Collega Synology",
connectNotion: "Collega Notion",
noCalendars: "Nessun calendario collegato.", noCalendars: "Nessun calendario collegato.",
dataPrivacy: "Dati e privacy", dataPrivacy: "Dati e privacy",
downloadData: "Scarica i miei dati", downloadData: "Scarica i miei dati",
@ -1230,6 +1244,7 @@ const translations: Record<string, any> = {
styling: "Stile", styling: "Stile",
motivation: "Motivazione", motivation: "Motivazione",
about: "Info", about: "Info",
setupAssistant: "Assistente di configurazione",
weekStartLabel: "La settimana inizia il", weekStartLabel: "La settimana inizia il",
startViewLabel: "Vista inizia con", startViewLabel: "Vista inizia con",
monday: "Lunedì", monday: "Lunedì",
@ -7167,7 +7182,7 @@ export default function WeeklyView() {
getSlotHeight(effectiveCellDuration) / effectiveCellDuration; getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
const topPosition = const topPosition =
minutesSinceStart * pixelsPerMinute; minutesSinceStart * pixelsPerMinute;
const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`; const timeString = formatHour(nowHour, nowMinute, "full", timeFormat);
return ( return (
<div <div
className="now-line" className="now-line"
@ -8066,7 +8081,7 @@ export default function WeeklyView() {
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null; list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
const iconContent = ( const iconContent = (
<span <span
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`} title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider === "notion" ? "Notion" : list.externalProvider}`}
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }} style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
> >
{list.externalProvider === "outlook" ? ( {list.externalProvider === "outlook" ? (
@ -8077,6 +8092,8 @@ export default function WeeklyView() {
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" /> <FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
) : list.externalProvider === "synology" ? ( ) : list.externalProvider === "synology" ? (
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" /> <FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
) : list.externalProvider === "notion" ? (
<FontAwesomeIcon icon={faNotion} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#000] dark:hover:text-[#fff] transition-colors" />
) : ( ) : (
<RefreshCcw size={14} className="text-zinc-400" /> <RefreshCcw size={14} className="text-zinc-400" />
)} )}
@ -8757,7 +8774,12 @@ export default function WeeklyView() {
<SettingsSidebar <SettingsSidebar
initialTab={activeTab} initialTab={activeTab}
onRemoveConnection={handleRemoveConnection} onRemoveConnection={handleRemoveConnection}
onClose={() => setShowSettings(false)} onClose={() => {
setShowSettings(false);
if (profile.hasCompletedOnboarding === false && localStorage.getItem("onboarding_step")) {
setShowOnboarding(true);
}
}}
onSettingsChanged={handleSettingsChanged} onSettingsChanged={handleSettingsChanged}
showTimeGrid={showTimeGrid} showTimeGrid={showTimeGrid}
setShowTimeGrid={setShowTimeGrid} setShowTimeGrid={setShowTimeGrid}
@ -8875,6 +8897,7 @@ export default function WeeklyView() {
saveViewSetting: saveViewSetting as any, saveViewSetting: saveViewSetting as any,
getEffective: getEffective as any, getEffective: getEffective as any,
}} }}
onRunSetupAssistant={() => setShowOnboarding(true)}
/> />
) )
} }
@ -8897,11 +8920,16 @@ export default function WeeklyView() {
darkMode={darkMode} darkMode={darkMode}
language={language} language={language}
connections={connections} connections={connections}
onComplete={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }} onComplete={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }} onSkip={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
saveSetting={saveSetting} saveSetting={saveSetting}
onLanguageChange={(lang: string) => { setLanguage(lang); }} onLanguageChange={(lang: string) => { setLanguage(lang); }}
onDarkModeToggle={() => setDarkMode(!darkMode)} onDarkModeToggle={() => setDarkMode(!darkMode)}
onConnectProvider={(provider: string) => {
setShowOnboarding(false);
setActiveTab("calendar");
setShowSettings(true);
}}
/> />
)} )}
@ -9744,6 +9772,7 @@ function TaskItem({
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" }, outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" }, apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" }, synology: { icon: faServer, color: "#007AFF", label: "Synology" },
notion: { icon: faNotion, color: "#000000", label: "Notion" },
}; };
const info = iconMap[provider]; const info = iconMap[provider];
if (!info) return null; if (!info) return null;
@ -10646,6 +10675,7 @@ interface SettingsSidebarProps {
saveViewSetting: (key: string, value: any, perView: boolean) => void; saveViewSetting: (key: string, value: any, perView: boolean) => void;
getEffective: (key: string, globalVal: any) => any; getEffective: (key: string, globalVal: any) => any;
}; };
onRunSetupAssistant?: () => void;
} }
// Notes Sidebar Component // Notes Sidebar Component
interface NotesSidebarProps { interface NotesSidebarProps {
@ -10871,6 +10901,7 @@ function SettingsSidebar({
isMobile: isMobileSidebar, isMobile: isMobileSidebar,
mobileActions, mobileActions,
perView, perView,
onRunSetupAssistant,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
@ -11084,6 +11115,10 @@ function SettingsSidebar({
window.location.href = "/api/calendar/outlook/start"; window.location.href = "/api/calendar/outlook/start";
}; };
const handleNotionConnect = () => {
window.location.href = "/api/calendar/notion/start";
};
const handleUpdateCalendar = async ( const handleUpdateCalendar = async (
connectionId: string, connectionId: string,
calendarId: string, calendarId: string,
@ -12191,7 +12226,9 @@ function SettingsSidebar({
? <FontAwesomeIcon icon={faApple} /> ? <FontAwesomeIcon icon={faApple} />
: conn.provider === "synology" : conn.provider === "synology"
? <FontAwesomeIcon icon={faServer} /> ? <FontAwesomeIcon icon={faServer} />
: <FontAwesomeIcon icon={faMicrosoft} />} : conn.provider === "notion"
? <FontAwesomeIcon icon={faNotion} />
: <FontAwesomeIcon icon={faMicrosoft} />}
</span> </span>
{conn.provider === "google" {conn.provider === "google"
? "Google Calendar" ? "Google Calendar"
@ -12199,7 +12236,9 @@ function SettingsSidebar({
? "Apple Calendar" ? "Apple Calendar"
: conn.provider === "synology" : conn.provider === "synology"
? "Synology Calendar" ? "Synology Calendar"
: "Outlook Calendar"} : conn.provider === "notion"
? "Notion"
: "Outlook Calendar"}
</div> </div>
{confirmDisconnectId === conn.id ? ( {confirmDisconnectId === conn.id ? (
<div <div
@ -12418,7 +12457,9 @@ function SettingsSidebar({
? t.noCalendarsApple ? t.noCalendarsApple
: conn.provider === "synology" : conn.provider === "synology"
? t.noCalendarsSynology ? t.noCalendarsSynology
: t.selectionAfterConnect} : conn.provider === "notion"
? t.selectionAfterConnect
: t.selectionAfterConnect}
</div> </div>
)} )}
</li> </li>
@ -12461,6 +12502,12 @@ function SettingsSidebar({
> >
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"} <FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
</button> </button>
<button
onClick={handleNotionConnect}
className="calendar-connect-btn"
>
<FontAwesomeIcon icon={faNotion} className="mr-2" /> {t.connectNotion || "Connect Notion"}
</button>
</div> </div>
<h3 <h3
@ -14332,6 +14379,24 @@ function SettingsSidebar({
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"} Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
</p> </p>
</div> </div>
<button
onClick={() => { onRunSetupAssistant?.(); }}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
width: "100%", padding: "12px 20px", borderRadius: "10px",
border: "1px solid var(--weekly-border, #e5e7eb)",
background: "var(--weekly-bg, #fff)",
color: "var(--weekly-text, #333)",
fontSize: "0.9rem", fontWeight: 500, cursor: "pointer",
transition: "all 0.15s",
}}
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--weekly-hover, #f3f4f6)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--weekly-bg, #fff)"; }}
>
<Play size={16} />
{t.setupAssistant}
</button>
</div> </div>
) : ( ) : (
/* Account Tab */ /* Account Tab */
@ -15035,6 +15100,13 @@ function SettingsSidebar({
</p> </p>
</div> </div>
<div className="bg-amber-50 border border-amber-200 rounded p-3 mb-4 text-sm text-amber-800" style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
<span style={{ fontSize: "1rem", flexShrink: 0 }}></span>
<p style={{ margin: 0, fontSize: "0.8rem", lineHeight: 1.4 }}>
{t.appleRemindersNote}
</p>
</div>
{appleCalError && ( {appleCalError && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm"> <div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
{appleCalError} {appleCalError}

View File

@ -2,6 +2,7 @@ import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomin
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar'; import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar';
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar'; import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar'; import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
import { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -42,7 +43,7 @@ export interface CalendarEvent {
recurrence?: string; recurrence?: string;
recurringEventId?: string; recurringEventId?: string;
isRecurring?: boolean; isRecurring?: boolean;
source: 'google' | 'apple' | 'outlook' | 'synology'; source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
calendarId: string; calendarId: string;
calendarTitle: string; calendarTitle: string;
backgroundColor?: string; backgroundColor?: string;
@ -581,6 +582,78 @@ export const getCalendarEvents = async (
} }
} }
} }
} else if (connection.provider === 'notion') {
console.log('[CALENDAR] Processing Notion connection:', connection.id);
// Refresh token if available
if (connection.refreshToken) {
try {
const refreshed = await refreshNotionToken(
connection.refreshToken,
process.env.NOTION_CLIENT_ID || '',
process.env.NOTION_CLIENT_SECRET || '',
);
accessToken = refreshed.accessToken;
await prisma.calendarConnection.update({
where: { id: connection.id },
data: {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
updatedAt: new Date(),
},
});
} catch (refreshErr) {
console.error('[CALENDAR] Notion token refresh failed, using existing token:', refreshErr);
}
}
// Get selected databases
const databases = (connection.calendars as any[] || []).filter((c: any) => c.selected !== false);
if (databases.length === 0) {
console.log('[CALENDAR] Notion: no databases selected, skipping');
}
for (const db of databases) {
if (!db.dateProperty) {
console.log(`[CALENDAR] Notion: database "${db.title}" has no date property, skipping`);
continue;
}
try {
const notionEvents = await getNotionEvents(
accessToken,
db.id,
db.dateProperty,
timeMin,
timeMax,
db.title,
);
console.log(`[CALENDAR] Notion: fetched ${notionEvents.length} events from "${db.title}"`);
events = events.concat(notionEvents.map((ne) => ({
id: ne.id,
title: ne.title,
description: ne.description,
start: {
dateTime: ne.allDay ? undefined : ne.start,
date: ne.allDay ? ne.start : undefined,
},
end: {
dateTime: ne.allDay ? undefined : (ne.end || ne.start),
date: ne.allDay ? (ne.end || ne.start) : undefined,
},
location: undefined,
url: ne.url,
source: 'notion' as const,
calendarId: db.id,
calendarTitle: db.title,
backgroundColor: '#000000',
})));
} catch (dbError) {
console.error(`[CALENDAR] Notion: error fetching from "${db.title}":`, dbError);
}
}
} }
console.log('[CALENDAR] Total events for connection:', events.length); console.log('[CALENDAR] Total events for connection:', events.length);
@ -868,13 +941,44 @@ export const createCalendarEvent = async (
id: createdEvent.id, id: createdEvent.id,
title: createdEvent.title, title: createdEvent.title,
description: createdEvent.description, description: createdEvent.description,
start: { dateTime: createdEvent.startDate }, start: { dateTime: createdEvent.startDate },
end: { dateTime: createdEvent.endDate }, end: { dateTime: createdEvent.endDate },
location: createdEvent.location, location: createdEvent.location,
source: 'synology', source: 'synology',
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'notion') {
if (!event.title) throw new Error('Event title is required');
if (!event.start) throw new Error('Event start time is required');
const databases = (connection.calendars as any[] || []);
const db = databases.find((d: any) => d.id === calendarId);
const dateProperty = db?.dateProperty;
if (!dateProperty) throw new Error('No date property found for this Notion database');
const startDate = event.start.dateTime || event.start.date || '';
const endDate = event.end?.dateTime || event.end?.date || undefined;
const { createEvent: createNotionEvent } = await import('./notion-calendar');
const pageId = await createNotionEvent(
connection.accessToken,
calendarId,
dateProperty,
event.title,
startDate,
endDate,
);
return {
id: pageId,
title: event.title,
start: event.start,
end: event.end || event.start,
source: 'notion',
calendarId,
calendarTitle: db?.title || '',
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support creating events yet.`); throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
@ -1060,6 +1164,34 @@ export const updateCalendarEvent = async (
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'notion') {
const databases = (connection.calendars as any[] || []);
const db = databases.find((d: any) => d.id === calendarId);
const dateProperty = db?.dateProperty;
if (!dateProperty) throw new Error('No date property found for this Notion database');
const startDate = event.start?.dateTime || event.start?.date || undefined;
const endDate = event.end?.dateTime || event.end?.date || undefined;
const { updateEvent: updateNotionEvent } = await import('./notion-calendar');
await updateNotionEvent(
connection.accessToken,
eventId,
dateProperty,
event.title,
startDate,
endDate,
);
return {
id: eventId,
title: event.title || '',
start: event.start || { dateTime: '' },
end: event.end || event.start || { dateTime: '' },
source: 'notion',
calendarId,
calendarTitle: db?.title || '',
} as CalendarEvent;
} }
throw new Error(`Provider ${connection.provider} does not support updating events yet.`); throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
@ -1125,6 +1257,10 @@ export const deleteCalendarEvent = async (
m.deleteEvent(serverUrl, username, password, calendarId, eventId) m.deleteEvent(serverUrl, username, password, calendarId, eventId)
); );
return; return;
} else if (connection.provider === 'notion') {
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
await deleteNotionEvent(connection.accessToken, eventId);
return;
} }
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`); throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);

View File

@ -29,7 +29,7 @@ export async function sendVerificationEmail(
token: string token: string
) { ) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}`; const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>

246
src/lib/notion-calendar.ts Normal file
View File

@ -0,0 +1,246 @@
import { Client } from '@notionhq/client';
export interface NotionDatabase {
id: string;
title: string;
selected: boolean;
dateProperty?: string; // name of the date property to use
}
export interface NotionEvent {
id: string;
title: string;
description?: string;
start: string; // ISO date or datetime
end?: string;
allDay: boolean;
url?: string;
databaseId: string;
databaseTitle: string;
}
/**
* Find all databases shared with the integration
*/
export async function getUserDatabases(accessToken: string): Promise<NotionDatabase[]> {
const notion = new Client({ auth: accessToken });
const response = await notion.search({
filter: { property: 'object', value: 'database' },
page_size: 50,
});
const databases: NotionDatabase[] = [];
for (const result of response.results) {
if (result.object !== 'database') continue;
const db = result as any;
const title = db.title?.map((t: any) => t.plain_text).join('') || 'Untitled';
// Find a date property in the database schema
let dateProperty: string | undefined;
if (db.properties) {
for (const [name, prop] of Object.entries(db.properties)) {
if ((prop as any).type === 'date') {
dateProperty = name;
break;
}
}
}
databases.push({
id: db.id,
title,
selected: true,
dateProperty,
});
}
return databases;
}
/**
* Query a Notion database for items with dates in the given range
*/
export async function getUpcomingEvents(
accessToken: string,
databaseId: string,
dateProperty: string,
timeMin: string,
timeMax: string,
databaseTitle: string,
): Promise<NotionEvent[]> {
const notion = new Client({ auth: accessToken });
const startDate = timeMin.split('T')[0];
const endDate = timeMax.split('T')[0];
let results: any[] = [];
let hasMore = true;
let startCursor: string | undefined;
while (hasMore) {
const response = await notion.databases.query({
database_id: databaseId,
filter: {
and: [
{
property: dateProperty,
date: { on_or_after: startDate },
},
{
property: dateProperty,
date: { on_or_before: endDate },
},
],
},
start_cursor: startCursor,
page_size: 100,
});
results = results.concat(response.results);
hasMore = response.has_more;
startCursor = response.next_cursor || undefined;
}
return results.map((page: any) => {
// Extract title from the first title property
let title = 'Untitled';
for (const [, prop] of Object.entries(page.properties)) {
const p = prop as any;
if (p.type === 'title' && p.title?.length > 0) {
title = p.title.map((t: any) => t.plain_text).join('');
break;
}
}
// Extract date
const dateProp = page.properties[dateProperty];
const dateVal = dateProp?.date;
const start = dateVal?.start || '';
const end = dateVal?.end || undefined;
const allDay = start.length === 10; // YYYY-MM-DD = all day
return {
id: page.id,
title,
description: undefined,
start,
end,
allDay,
url: page.url,
databaseId,
databaseTitle,
};
});
}
/**
* Create a page in a Notion database
*/
export async function createEvent(
accessToken: string,
databaseId: string,
dateProperty: string,
title: string,
startDate: string,
endDate?: string,
): Promise<string> {
const notion = new Client({ auth: accessToken });
const page = await notion.pages.create({
parent: { database_id: databaseId },
properties: {
// The title property - find it dynamically
title: {
title: [{ text: { content: title } }],
},
[dateProperty]: {
date: {
start: startDate,
end: endDate || null,
},
},
} as any,
});
return page.id;
}
/**
* Update a page in Notion
*/
export async function updateEvent(
accessToken: string,
pageId: string,
dateProperty: string,
title?: string,
startDate?: string,
endDate?: string,
): Promise<void> {
const notion = new Client({ auth: accessToken });
const properties: any = {};
if (title !== undefined) {
properties.title = { title: [{ text: { content: title } }] };
}
if (startDate !== undefined) {
properties[dateProperty] = {
date: { start: startDate, end: endDate || null },
};
}
await notion.pages.update({
page_id: pageId,
properties,
});
}
/**
* Archive (soft-delete) a page in Notion
*/
export async function deleteEvent(
accessToken: string,
pageId: string,
): Promise<void> {
const notion = new Client({ auth: accessToken });
await notion.pages.update({
page_id: pageId,
archived: true,
});
}
/**
* Refresh Notion OAuth token
*/
export async function refreshAccessToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): Promise<{ accessToken: string; refreshToken: string; expiresAt?: Date }> {
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const response = await fetch('https://api.notion.com/v1/oauth/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Notion token refresh failed: ${error}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresAt: undefined, // Notion doesn't return expiry in refresh
};
}

File diff suppressed because one or more lines are too long