From 4ac73d3e03bbbdf8cdf9c72576043abe1a1ac72b Mon Sep 17 00:00:00 2001 From: mARTin Date: Sun, 22 Mar 2026 13:52:29 +0100 Subject: [PATCH] feat: onboarding wizard for first-time users + UI fixes - 6-step onboarding wizard (welcome, language, connect providers, view style, work hours, styling) for new signups - Added hasCompletedOnboarding field to User schema - Fixed subtask progress bar in GridTaskBlock (simple/calendar views) - Fixed kanban drag-and-drop to someday area - Fixed weather/provider icon overlap in time grid tasks - Provider icon positioning: inline when weather on, top-right when off v1.56.0 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- prisma/schema.prisma | 1 + src/app/api/auth/signup/route.ts | 1 + src/app/api/user/profile/route.ts | 6 +- src/app/globals.css | 53 +++ src/components/GridTaskBlock.tsx | 117 +++-- src/components/OnboardingWizard.tsx | 659 ++++++++++++++++++++++++++++ src/components/WeeklyView.tsx | 30 +- src/lib/auth.ts | 11 + 9 files changed, 840 insertions(+), 40 deletions(-) create mode 100644 src/components/OnboardingWizard.tsx diff --git a/package.json b/package.json index e724187..052d031 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.55.3", + "version": "1.56.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1e0880..8ccc3e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -116,6 +116,7 @@ model User { weatherLon Float? weatherLocation String? weatherRecentCities Json? + hasCompletedOnboarding Boolean @default(true) notificationsEnabled Boolean @default(false) somedayLists SomedayList[] tasks Task[] diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 091b3a0..6430e54 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -80,6 +80,7 @@ export async function POST(request: NextRequest) { emailVerificationCode: code, emailVerificationToken: token, emailVerificationExpires: expires, + hasCompletedOnboarding: false, // emailVerified is NOT set — user must verify }, select: { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 4046454..c09e956 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -103,6 +103,7 @@ export async function GET(request: NextRequest) { weatherLocation: true, weatherRecentCities: true, viewSettings: true, + hasCompletedOnboarding: true, createdAt: true } }); @@ -146,7 +147,8 @@ export async function PATCH(request: NextRequest) { showTaskCheckboxes, dayHeaderGap, showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages, kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale, - weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings + weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings, + hasCompletedOnboarding } = body; const updateData: any = { @@ -238,6 +240,7 @@ export async function PATCH(request: NextRequest) { ...(weatherLocation !== undefined && { weatherLocation }), ...(weatherRecentCities !== undefined && { weatherRecentCities }), ...(viewSettings !== undefined && { viewSettings }), + ...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -338,6 +341,7 @@ export async function PATCH(request: NextRequest) { weatherLocation: true, weatherRecentCities: true, viewSettings: true, + hasCompletedOnboarding: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index 6029a7f..5d2a02b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4861,3 +4861,56 @@ h3 { .time-grid-on .time-column-header { z-index: 45 !important; } + +/* ============================================ + ONBOARDING WIZARD + ============================================ */ +.onboarding-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + justify-content: center; + align-items: center; +} + +.onboarding-card { + border-radius: 16px; + width: 90%; + max-width: 560px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: onboarding-appear 0.3s ease-out; + position: relative; +} + +@keyframes onboarding-appear { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.onboarding-step-forward { animation: ob-slide-right 0.25s ease-out; } +.onboarding-step-backward { animation: ob-slide-left 0.25s ease-out; } + +@keyframes ob-slide-right { + from { opacity: 0; transform: translateX(30px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes ob-slide-left { + from { opacity: 0; transform: translateX(-30px); } + to { opacity: 1; transform: translateX(0); } +} + +@media (max-width: 480px) { + .onboarding-card { + width: 100%; + max-width: 100%; + border-radius: 0; + max-height: 100vh; + height: 100vh; + } +} diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index adc4878..0518141 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; -import { Repeat, Circle, X } from "lucide-react"; +import { Repeat, Circle, X, ChevronDown } from "lucide-react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons"; import { faServer } from "@fortawesome/free-solid-svg-icons"; @@ -36,6 +36,7 @@ interface GridTaskBlockProps { projects?: any[]; onProjectAssign?: (taskId: string, projectId: string | null) => void; kanbanStages?: KanbanStage[]; + weatherEnabled?: boolean; } export function GridTaskBlock({ @@ -68,7 +69,8 @@ export function GridTaskBlock({ showTaskCheckboxes, projects, onProjectAssign, - kanbanStages = [] + kanbanStages = [], + weatherEnabled = false }: GridTaskBlockProps) { const [isNotesOpen, setIsNotesOpen] = useState(false); const [notesValue, setNotesValue] = useState(task.markdownContent || ""); @@ -285,9 +287,11 @@ export function GridTaskBlock({ {task.subTasks && task.subTasks.length > 0 && (() => { const completed = task.subTasks.filter(s => s.completed).length; const total = task.subTasks.length; + const allDone = completed === total; const expanded = isSubTasksOpen || isSubTaskInputOpen; + const pct = total > 0 ? (completed / total) * 100 : 0; return ( -
{ e.stopPropagation(); setIsSubTasksOpen(!isSubTasksOpen); @@ -297,25 +301,45 @@ export function GridTaskBlock({ style={{ display: "inline-flex", alignItems: "center", - gap: "2px", - padding: "1px 5px", - borderRadius: "8px", - background: expanded ? "rgba(99, 102, 241, 0.15)" : (completed === total) ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.06)", - color: expanded ? "#6366f1" : (completed === total) ? "#22c55e" : (darkMode ? "#aaa" : "#666"), + gap: "4px", + padding: "2px 8px 2px 4px", + borderRadius: "12px", + background: expanded ? "rgba(99,102,241,0.08)" : "transparent", + color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)", border: "none", cursor: "pointer", - fontSize: "0.65rem", + fontSize: "0.7rem", fontWeight: 600, lineHeight: 1, flexShrink: 0, whiteSpace: "nowrap", + transition: "all 0.15s", }} > - - - - {completed}/{total} -
+ + + + + {completed}/{total} + ); })()} @@ -344,32 +368,53 @@ export function GridTaskBlock({ )} + {weatherEnabled && (() => { + const provider = task.externalProvider + || (task.externalId?.startsWith("synology::") ? "synology" : null); + if (!provider) return null; + const iconMap: Record = { + google: { icon: faGoogle, color: "#4285F4", label: "Google" }, + outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" }, + apple: { icon: faApple, color: "#555", label: "Apple" }, + synology: { icon: faServer, color: "#007AFF", label: "Synology" }, + }; + const info = iconMap[provider]; + if (!info) return null; + return ( + + + + ); + })()} )} - {(() => { - const provider = task.externalProvider - || (task.externalId?.startsWith("synology::") ? "synology" : null); - if (!provider) return null; - const iconMap: Record = { - google: { icon: faGoogle, color: "#4285F4", label: "Google" }, - outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" }, - apple: { icon: faApple, color: "#555", label: "Apple" }, - synology: { icon: faServer, color: "#007AFF", label: "Synology" }, - }; - const info = iconMap[provider]; - if (!info) return null; - return ( - - - - ); - })()} + {!weatherEnabled && (() => { + const provider = task.externalProvider + || (task.externalId?.startsWith("synology::") ? "synology" : null); + if (!provider) return null; + const iconMap: Record = { + google: { icon: faGoogle, color: "#4285F4", label: "Google" }, + outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" }, + apple: { icon: faApple, color: "#555", label: "Apple" }, + synology: { icon: faServer, color: "#007AFF", label: "Synology" }, + }; + const info = iconMap[provider]; + if (!info) return null; + return ( + + + + ); + })()}
void; + onSkip: () => void; + saveSetting: (key: string, value: any) => Promise; + onLanguageChange: (lang: string) => void; + onDarkModeToggle: () => void; +} + +const STEPS = ["welcome", "language", "connect", "view", "schedule", "style"] as const; + +const translations: Record> = { + en: { + welcomeTitle: "Welcome to My Weekly ToDo List", + welcomeSubtitle: "Let's set up your workspace in under a minute", + getStarted: "Get Started", + skipSetup: "Skip setup", + next: "Next", + back: "Back", + finish: "Start Using My Weekly ToDo List", + // Step 2 + langTitle: "Language & Region", + langSubtitle: "Choose your language and timezone", + timezone: "Timezone", + // Step 3 + connectTitle: "Connect Your Services", + connectSubtitle: "Sync calendars and tasks from your favorite services", + connectGoogle: "Google Calendar & Tasks", + connectApple: "Apple Calendar & Reminders", + connectOutlook: "Microsoft Outlook", + connectSynology: "Synology Calendar", + connected: "Connected", + connect: "Connect", + skipConnect: "Skip for now - you can connect later in Settings", + // Step 4 + viewTitle: "Choose Your View", + viewSubtitle: "Pick the layout that fits how you work", + viewSimple: "Simple", + viewSimpleDesc: "Clean weekly grid with time slots", + viewCalendar: "Calendar", + viewCalendarDesc: "Google Calendar-style day view", + viewList: "List", + viewListDesc: "Focused task list without time grid", + viewKanban: "Kanban", + viewKanbanDesc: "Drag-and-drop board with stages", + // Step 5 + scheduleTitle: "Your Work Hours", + scheduleSubtitle: "Set when your day starts and ends", + startHour: "Start", + endHour: "End", + viewDays: "Days per week", + // Step 6 + styleTitle: "Make It Yours", + styleSubtitle: "Customize fonts and appearance", + headlineFont: "Headline font", + bodyFont: "Body font", + fontSize: "Font size", + darkMode: "Dark mode", + }, + de: { + welcomeTitle: "Willkommen bei My Weekly ToDo List", + welcomeSubtitle: "Richte deinen Arbeitsbereich in unter einer Minute ein", + getStarted: "Los geht's", + skipSetup: "Setup uberspringen", + next: "Weiter", + back: "Zuruck", + finish: "My Weekly ToDo List starten", + langTitle: "Sprache & Region", + langSubtitle: "Wahle deine Sprache und Zeitzone", + timezone: "Zeitzone", + connectTitle: "Dienste verbinden", + connectSubtitle: "Synchronisiere Kalender und Aufgaben", + connectGoogle: "Google Kalender & Aufgaben", + connectApple: "Apple Kalender & Erinnerungen", + connectOutlook: "Microsoft Outlook", + connectSynology: "Synology Kalender", + connected: "Verbunden", + connect: "Verbinden", + skipConnect: "Jetzt uberspringen - du kannst spater in den Einstellungen verbinden", + viewTitle: "Wahle deine Ansicht", + viewSubtitle: "Wahle das Layout, das zu dir passt", + viewSimple: "Einfach", + viewSimpleDesc: "Ubersichtliches Wochenraster mit Zeitfenstern", + viewCalendar: "Kalender", + viewCalendarDesc: "Google Calendar-Stil Tagesansicht", + viewList: "Liste", + viewListDesc: "Fokussierte Aufgabenliste ohne Zeitraster", + viewKanban: "Kanban", + viewKanbanDesc: "Drag-and-Drop Board mit Spalten", + scheduleTitle: "Deine Arbeitszeiten", + scheduleSubtitle: "Lege fest, wann dein Tag beginnt und endet", + startHour: "Start", + endHour: "Ende", + viewDays: "Tage pro Woche", + styleTitle: "Gestalte es nach deinen Wunschen", + styleSubtitle: "Passe Schriftarten und Aussehen an", + headlineFont: "Uberschrift-Schrift", + bodyFont: "Text-Schrift", + fontSize: "Schriftgrosse", + darkMode: "Dunkler Modus", + }, + fr: { + welcomeTitle: "Bienvenue sur My Weekly ToDo List", + welcomeSubtitle: "Configurez votre espace en moins d'une minute", + getStarted: "Commencer", + skipSetup: "Passer la configuration", + next: "Suivant", + back: "Retour", + finish: "Commencer a utiliser My Weekly ToDo List", + langTitle: "Langue & Region", + langSubtitle: "Choisissez votre langue et fuseau horaire", + timezone: "Fuseau horaire", + connectTitle: "Connectez vos services", + connectSubtitle: "Synchronisez calendriers et taches", + connectGoogle: "Google Agenda & Taches", + connectApple: "Calendrier Apple & Rappels", + connectOutlook: "Microsoft Outlook", + connectSynology: "Calendrier Synology", + connected: "Connecte", + connect: "Connecter", + skipConnect: "Passer pour l'instant - vous pourrez connecter plus tard", + viewTitle: "Choisissez votre vue", + viewSubtitle: "Selectionnez la mise en page qui vous convient", + viewSimple: "Simple", + viewSimpleDesc: "Grille hebdomadaire avec creneaux horaires", + viewCalendar: "Calendrier", + viewCalendarDesc: "Vue journaliere style Google Calendar", + viewList: "Liste", + viewListDesc: "Liste de taches sans grille horaire", + viewKanban: "Kanban", + viewKanbanDesc: "Tableau drag-and-drop avec colonnes", + scheduleTitle: "Vos horaires de travail", + scheduleSubtitle: "Definissez le debut et la fin de votre journee", + startHour: "Debut", + endHour: "Fin", + viewDays: "Jours par semaine", + styleTitle: "Personnalisez", + styleSubtitle: "Choisissez vos polices et apparence", + headlineFont: "Police de titre", + bodyFont: "Police de texte", + fontSize: "Taille de police", + darkMode: "Mode sombre", + }, + es: { + welcomeTitle: "Bienvenido a My Weekly ToDo List", + welcomeSubtitle: "Configura tu espacio en menos de un minuto", + getStarted: "Empezar", + skipSetup: "Omitir configuracion", + next: "Siguiente", + back: "Atras", + finish: "Empezar a usar My Weekly ToDo List", + langTitle: "Idioma y Region", + langSubtitle: "Elige tu idioma y zona horaria", + timezone: "Zona horaria", + connectTitle: "Conecta tus servicios", + connectSubtitle: "Sincroniza calendarios y tareas", + connectGoogle: "Google Calendar y Tareas", + connectApple: "Calendario Apple y Recordatorios", + connectOutlook: "Microsoft Outlook", + connectSynology: "Calendario Synology", + connected: "Conectado", + connect: "Conectar", + skipConnect: "Omitir por ahora - puedes conectar despues en Ajustes", + viewTitle: "Elige tu vista", + viewSubtitle: "Selecciona el diseno que mejor se adapte a ti", + viewSimple: "Simple", + viewSimpleDesc: "Cuadricula semanal con franjas horarias", + viewCalendar: "Calendario", + viewCalendarDesc: "Vista diaria estilo Google Calendar", + viewList: "Lista", + viewListDesc: "Lista de tareas sin cuadricula horaria", + viewKanban: "Kanban", + viewKanbanDesc: "Tablero arrastrar y soltar con columnas", + scheduleTitle: "Tu horario de trabajo", + scheduleSubtitle: "Define cuando empieza y termina tu dia", + startHour: "Inicio", + endHour: "Fin", + viewDays: "Dias por semana", + styleTitle: "Hazlo tuyo", + styleSubtitle: "Personaliza fuentes y apariencia", + headlineFont: "Fuente de titulo", + bodyFont: "Fuente de texto", + fontSize: "Tamano de fuente", + darkMode: "Modo oscuro", + }, +}; + +const COMMON_TIMEZONES = [ + "Europe/Berlin", "Europe/London", "Europe/Paris", "Europe/Madrid", + "Europe/Rome", "Europe/Amsterdam", "Europe/Zurich", "Europe/Vienna", + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Toronto", "America/Sao_Paulo", "America/Mexico_City", + "Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Singapore", "Asia/Dubai", + "Australia/Sydney", "Pacific/Auckland", "UTC" +]; + +const LANGUAGES = [ + { code: "de", label: "Deutsch", flag: "DE" }, + { code: "en", label: "English", flag: "EN" }, + { code: "fr", label: "Francais", flag: "FR" }, + { code: "es", label: "Espanol", flag: "ES" }, +]; + +export default function OnboardingWizard({ + profile, + darkMode, + language, + connections, + onComplete, + onSkip, + saveSetting, + onLanguageChange, + onDarkModeToggle, +}: OnboardingWizardProps) { + const [currentStep, setCurrentStep] = useState(0); + const [direction, setDirection] = useState<"forward" | "backward">("forward"); + const [animKey, setAnimKey] = useState(0); + + // Local state for settings + const [selectedLang, setSelectedLang] = useState(language || "de"); + const [selectedTimezone, setSelectedTimezone] = useState(profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"); + const [selectedView, setSelectedView] = useState(profile.viewStyle || "simple"); + const [selectedStartHour, setSelectedStartHour] = useState(profile.startHour ?? 8); + const [selectedEndHour, setSelectedEndHour] = useState(profile.endHour ?? 18); + const [selectedViewDays, setSelectedViewDays] = useState(profile.viewDays ?? 7); + const [selectedHeadlineFont, setSelectedHeadlineFont] = useState(profile.headlineFont || "Oswald"); + const [selectedBodyFont, setSelectedBodyFont] = useState(profile.bodyFont || "Inter"); + const [selectedFontSize, setSelectedFontSize] = useState(profile.fontSize || "M"); + + // Resume from localStorage if returning from OAuth + useEffect(() => { + const savedStep = localStorage.getItem("onboarding_step"); + if (savedStep) { + setCurrentStep(parseInt(savedStep, 10)); + localStorage.removeItem("onboarding_step"); + } + }, []); + + const t = translations[selectedLang] || translations.en; + + const goNext = async () => { + await saveCurrentStep(); + setDirection("forward"); + setAnimKey((k) => k + 1); + setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1)); + }; + + const goBack = () => { + setDirection("backward"); + setAnimKey((k) => k + 1); + setCurrentStep((s) => Math.max(s - 1, 0)); + }; + + const saveCurrentStep = async () => { + const step = STEPS[currentStep]; + if (step === "language") { + await saveSetting("language", selectedLang); + await saveSetting("timezone", selectedTimezone); + onLanguageChange(selectedLang); + } else if (step === "view") { + await saveSetting("viewStyle", selectedView); + await saveSetting("showTimeGrid", selectedView === "simple" || selectedView === "calendar"); + } else if (step === "schedule") { + await saveSetting("startHour", selectedStartHour); + await saveSetting("endHour", selectedEndHour); + await saveSetting("viewDays", selectedViewDays); + } else if (step === "style") { + await saveSetting("headlineFont", selectedHeadlineFont); + await saveSetting("bodyFont", selectedBodyFont); + await saveSetting("fontSize", selectedFontSize); + } + }; + + const handleFinish = async () => { + await saveCurrentStep(); + onComplete(); + }; + + const connectedProviders = new Set(connections.map((c: any) => c.provider)); + + const handleOAuthConnect = (url: string) => { + localStorage.setItem("onboarding_step", String(currentStep)); + window.location.href = url; + }; + + const bg = darkMode ? "#1f2937" : "#ffffff"; + const textColor = darkMode ? "#e5e7eb" : "#333333"; + const mutedColor = darkMode ? "#9ca3af" : "#6b7280"; + const borderColor = darkMode ? "#374151" : "#e5e7eb"; + const accentColor = "#0d9488"; + const cardBg = darkMode ? "#111827" : "#f9fafb"; + + const btnStyle: React.CSSProperties = { + display: "inline-flex", alignItems: "center", gap: "6px", + padding: "10px 24px", borderRadius: "10px", border: "none", + fontSize: "0.95rem", fontWeight: 600, cursor: "pointer", + transition: "all 0.15s", + }; + const primaryBtn: React.CSSProperties = { ...btnStyle, background: accentColor, color: "#fff" }; + const secondaryBtn: React.CSSProperties = { ...btnStyle, background: darkMode ? "#374151" : "#f3f4f6", color: textColor }; + + const isLastStep = currentStep === STEPS.length - 1; + + const renderStep = () => { + const step = STEPS[currentStep]; + + if (step === "welcome") { + return ( +
+
+

+ {t.welcomeTitle} +

+

+ {t.welcomeSubtitle} +

+ +
+ +
+
+ ); + } + + if (step === "language") { + return ( +
+

{t.langTitle}

+

{t.langSubtitle}

+ +
+ {LANGUAGES.map((lang) => ( + + ))} +
+ + + +
+ ); + } + + if (step === "connect") { + const providers = [ + { key: "google", label: t.connectGoogle, icon: faGoogle, color: "#4285F4", action: () => handleOAuthConnect("/api/calendar/google/start") }, + { key: "apple", label: t.connectApple, icon: faApple, color: darkMode ? "#ccc" : "#333", action: () => {} }, + { key: "outlook", label: t.connectOutlook, icon: faMicrosoft, color: "#0078D4", action: () => {} }, + { key: "synology", label: t.connectSynology, icon: faServer, color: "#007AFF", action: () => {} }, + ]; + return ( +
+

{t.connectTitle}

+

{t.connectSubtitle}

+ +
+ {providers.map((p) => { + const isConnected = connectedProviders.has(p.key); + return ( +
+ + {p.label} + {isConnected ? ( + + {t.connected} + + ) : ( + + )} +
+ ); + })} +
+

{t.skipConnect}

+
+ ); + } + + if (step === "view") { + const views = [ + { key: "simple", label: t.viewSimple, desc: t.viewSimpleDesc, Icon: LayoutGrid }, + { key: "calendar", label: t.viewCalendar, desc: t.viewCalendarDesc, Icon: CalendarDays }, + { key: "list", label: t.viewList, desc: t.viewListDesc, Icon: ListTodo }, + { key: "kanban", label: t.viewKanban, desc: t.viewKanbanDesc, Icon: Kanban }, + ]; + return ( +
+

{t.viewTitle}

+

{t.viewSubtitle}

+ +
+ {views.map((v) => { + const selected = selectedView === v.key; + return ( + + ); + })} +
+
+ ); + } + + if (step === "schedule") { + return ( +
+

{t.scheduleTitle}

+

{t.scheduleSubtitle}

+ +
+
+ +
+ setSelectedStartHour(parseInt(e.target.value))} + style={{ flex: 1, accentColor }} + /> + + {String(selectedStartHour).padStart(2, "0")}:00 + +
+
+
+ +
+ setSelectedEndHour(parseInt(e.target.value))} + style={{ flex: 1, accentColor }} + /> + + {String(selectedEndHour).padStart(2, "0")}:00 + +
+
+
+ + +
+ {[5, 7].map((d) => ( + + ))} +
+
+ ); + } + + if (step === "style") { + return ( +
+

{t.styleTitle}

+

{t.styleSubtitle}

+ +
+ + +
+ +
+ + +
+ +
+ +
+ {(["S", "M", "L"] as const).map((size) => ( + + ))} +
+
+ +
+ {t.darkMode} + +
+
+ ); + } + + return null; + }; + + return ( +
+
+ {/* Close button */} + {currentStep > 0 && ( + + )} + + {/* Step content with animation */} +
+ {renderStep()} +
+ + {/* Footer with progress + nav (not on welcome) */} + {currentStep > 0 && ( +
+ + + {/* Progress dots */} +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ + {isLastStep ? ( + + ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index c928554..d4dd365 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -88,6 +88,7 @@ import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; } import { ImportListModal } from "./ImportListModal"; +import OnboardingWizard from "./OnboardingWizard"; import { getRandomLocalQuote } from "@/lib/quotes"; // Cookie helpers for per-device settings @@ -1822,6 +1823,7 @@ export default function WeeklyView() { // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); + const [showOnboarding, setShowOnboarding] = useState(false); const [activeTab, setActiveTab] = useState< "calendar" | "general" | "account" | "styling" | "motivation" | "about" >("general"); @@ -2236,6 +2238,11 @@ export default function WeeklyView() { if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots); if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition); if (profileData.viewSettings) setViewSettings(profileData.viewSettings); + + // Show onboarding wizard for new users + if (profileData.hasCompletedOnboarding === false) { + setShowOnboarding(true); + } } } } catch (err) { @@ -6463,8 +6470,11 @@ export default function WeeklyView() { draggable onDragStart={(e) => { e.dataTransfer.setData("text/kanban-task", task.id); + e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.effectAllowed = "move"; + setDraggedTask(task); }} + onDragEnd={() => setDraggedTask(null)} onClick={(e) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.contentEditable === "true" || target.closest("button") || target.closest(".kanban-card-subtask-list")) return; @@ -7280,6 +7290,7 @@ export default function WeeklyView() { projects={projects} onProjectAssign={assignProject} kanbanStages={kanbanStages} + weatherEnabled={effectiveWeatherEnabled} /> ))} {visibleSlots.map((slot) => { @@ -7369,7 +7380,7 @@ export default function WeeklyView() { if (d.includes("humidity") && w.humidity != null) parts.push(`💧${w.humidity}%`); if (d.includes("uv") && w.uv != null && w.uv > 0) parts.push(`UV${w.uv}`); return ( -
+
{d.includes("icon") && {getWeatherIcon(w.code)}} {parts.length > 0 && {parts.join(" ")}}
@@ -8879,6 +8890,21 @@ export default function WeeklyView() { ) } + {/* Onboarding Wizard */} + {showOnboarding && ( + { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }} + onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }} + saveSetting={saveSetting} + onLanguageChange={(lang: string) => { setLanguage(lang); }} + onDarkModeToggle={() => setDarkMode(!darkMode)} + /> + )} + { selectedTaskForNotes && ( diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ba76f28..a10bcb0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -136,5 +136,16 @@ export const authOptions: NextAuthOptions = { return session; } }, + events: { + async createUser({ user }) { + // New OAuth users should see the onboarding wizard + if (user.id) { + await prisma.user.update({ + where: { id: user.id }, + data: { hasCompletedOnboarding: false }, + }); + } + }, + }, secret: process.env.NEXTAUTH_SECRET }; \ No newline at end of file