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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-22 13:52:29 +01:00
parent 452e1a0eab
commit 4ac73d3e03
9 changed files with 840 additions and 40 deletions

View File

@ -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": {

View File

@ -116,6 +116,7 @@ model User {
weatherLon Float?
weatherLocation String?
weatherRecentCities Json?
hasCompletedOnboarding Boolean @default(true)
notificationsEnabled Boolean @default(false)
somedayLists SomedayList[]
tasks Task[]

View File

@ -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: {

View File

@ -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,
}
});

View File

@ -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;
}
}

View File

@ -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 (
<div
<button
onClick={(e) => {
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",
}}
>
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
{completed}/{total}
</div>
<ChevronDown size={12} style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<span style={{
display: "inline-block",
width: "36px",
height: "5px",
borderRadius: "3px",
background: "var(--weekly-border, #e5e7eb)",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}>
<span style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${pct}%`,
borderRadius: "3px",
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
transition: "width 0.3s ease",
}} />
</span>
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
</button>
);
})()}
@ -344,10 +368,7 @@ export function GridTaskBlock({
</svg>
</div>
)}
</span>
</span>
)}
{(() => {
{weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
@ -363,13 +384,37 @@ export function GridTaskBlock({
<span
className="flex-shrink-0"
title={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
</span>
</span>
)}
</div>
{!weatherEnabled && (() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
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 (
<span
title={`Synced with ${info.label}`}
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
<div
className="task-actions"

View File

@ -0,0 +1,659 @@
"use client";
import React, { useState, useEffect } from "react";
import { LayoutGrid, CalendarDays, ListTodo, Kanban, ChevronRight, ChevronLeft, X, Sun, Moon, Check } from "lucide-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle, faApple, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import FontPicker from "./FontPicker";
interface OnboardingWizardProps {
profile: any;
darkMode: boolean;
language: string;
connections: any[];
onComplete: () => void;
onSkip: () => void;
saveSetting: (key: string, value: any) => Promise<void>;
onLanguageChange: (lang: string) => void;
onDarkModeToggle: () => void;
}
const STEPS = ["welcome", "language", "connect", "view", "schedule", "style"] as const;
const translations: Record<string, Record<string, string>> = {
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 (
<div style={{ textAlign: "center", padding: "40px 30px" }}>
<div style={{ fontSize: "3rem", marginBottom: "16px" }}>&#10003;</div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, margin: "0 0 12px", color: textColor }}>
{t.welcomeTitle}
</h1>
<p style={{ color: mutedColor, fontSize: "1rem", margin: "0 0 32px", lineHeight: 1.5 }}>
{t.welcomeSubtitle}
</p>
<button onClick={goNext} style={primaryBtn}>
{t.getStarted} <ChevronRight size={16} />
</button>
<div style={{ marginTop: "16px" }}>
<button onClick={onSkip} style={{ background: "none", border: "none", color: mutedColor, cursor: "pointer", fontSize: "0.85rem", textDecoration: "underline" }}>
{t.skipSetup}
</button>
</div>
</div>
);
}
if (step === "language") {
return (
<div style={{ padding: "30px" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.langTitle}</h2>
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.langSubtitle}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", marginBottom: "24px" }}>
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => setSelectedLang(lang.code)}
style={{
padding: "14px 16px", borderRadius: "10px", border: `2px solid ${selectedLang === lang.code ? accentColor : borderColor}`,
background: selectedLang === lang.code ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
cursor: "pointer", textAlign: "left", display: "flex", alignItems: "center", gap: "10px",
color: textColor, fontSize: "0.95rem", fontWeight: selectedLang === lang.code ? 600 : 400,
transition: "all 0.15s",
}}
>
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
{lang.label}
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
</button>
))}
</div>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.timezone}</label>
<select
value={selectedTimezone}
onChange={(e) => setSelectedTimezone(e.target.value)}
style={{
width: "100%", padding: "10px 12px", borderRadius: "8px", border: `1px solid ${borderColor}`,
background: darkMode ? "#111827" : "#fff", color: textColor, fontSize: "0.9rem",
}}
>
{COMMON_TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
))}
</select>
</div>
);
}
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 (
<div style={{ padding: "30px" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.connectTitle}</h2>
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.connectSubtitle}</p>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{providers.map((p) => {
const isConnected = connectedProviders.has(p.key);
return (
<div
key={p.key}
style={{
display: "flex", alignItems: "center", gap: "12px",
padding: "14px 16px", borderRadius: "10px",
border: `1px solid ${isConnected ? accentColor : borderColor}`,
background: isConnected ? (darkMode ? "rgba(13,148,136,0.1)" : "rgba(13,148,136,0.04)") : "transparent",
}}
>
<FontAwesomeIcon icon={p.icon} style={{ width: 20, height: 20, color: p.color }} />
<span style={{ flex: 1, fontSize: "0.95rem", color: textColor, fontWeight: 500 }}>{p.label}</span>
{isConnected ? (
<span style={{ fontSize: "0.8rem", color: accentColor, fontWeight: 600, display: "flex", alignItems: "center", gap: "4px" }}>
<Check size={14} /> {t.connected}
</span>
) : (
<button
onClick={p.action}
style={{
padding: "6px 16px", borderRadius: "6px", border: `1px solid ${borderColor}`,
background: "transparent", color: textColor, fontSize: "0.8rem",
fontWeight: 500, cursor: "pointer",
}}
>
{t.connect}
</button>
)}
</div>
);
})}
</div>
<p style={{ color: mutedColor, fontSize: "0.8rem", marginTop: "16px", textAlign: "center" }}>{t.skipConnect}</p>
</div>
);
}
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 (
<div style={{ padding: "30px" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.viewTitle}</h2>
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.viewSubtitle}</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
{views.map((v) => {
const selected = selectedView === v.key;
return (
<button
key={v.key}
onClick={() => setSelectedView(v.key)}
style={{
padding: "20px 16px", borderRadius: "12px",
border: `2px solid ${selected ? accentColor : borderColor}`,
background: selected ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
cursor: "pointer", textAlign: "center",
display: "flex", flexDirection: "column", alignItems: "center", gap: "8px",
color: textColor, transition: "all 0.15s",
}}
>
<v.Icon size={28} strokeWidth={1.5} style={{ color: selected ? accentColor : mutedColor }} />
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>{v.label}</span>
<span style={{ fontSize: "0.75rem", color: mutedColor, lineHeight: 1.3 }}>{v.desc}</span>
</button>
);
})}
</div>
</div>
);
}
if (step === "schedule") {
return (
<div style={{ padding: "30px" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.scheduleTitle}</h2>
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.scheduleSubtitle}</p>
<div style={{ display: "flex", gap: "16px", marginBottom: "24px" }}>
<div style={{ flex: 1 }}>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.startHour}</label>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
type="range" min={0} max={12} value={selectedStartHour}
onChange={(e) => setSelectedStartHour(parseInt(e.target.value))}
style={{ flex: 1, accentColor }}
/>
<span style={{ fontSize: "1.1rem", fontWeight: 700, color: textColor, minWidth: "40px", textAlign: "center" }}>
{String(selectedStartHour).padStart(2, "0")}:00
</span>
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.endHour}</label>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
type="range" min={12} max={24} value={selectedEndHour}
onChange={(e) => setSelectedEndHour(parseInt(e.target.value))}
style={{ flex: 1, accentColor }}
/>
<span style={{ fontSize: "1.1rem", fontWeight: 700, color: textColor, minWidth: "40px", textAlign: "center" }}>
{String(selectedEndHour).padStart(2, "0")}:00
</span>
</div>
</div>
</div>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "10px" }}>{t.viewDays}</label>
<div style={{ display: "flex", gap: "8px" }}>
{[5, 7].map((d) => (
<button
key={d}
onClick={() => setSelectedViewDays(d)}
style={{
flex: 1, padding: "12px", borderRadius: "10px",
border: `2px solid ${selectedViewDays === d ? accentColor : borderColor}`,
background: selectedViewDays === d ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
color: textColor, fontSize: "1rem", fontWeight: 600, cursor: "pointer",
transition: "all 0.15s",
}}
>
{d}
</button>
))}
</div>
</div>
);
}
if (step === "style") {
return (
<div style={{ padding: "30px" }}>
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.styleTitle}</h2>
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.styleSubtitle}</p>
<div style={{ marginBottom: "20px" }}>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.headlineFont}</label>
<FontPicker value={selectedHeadlineFont} onChange={setSelectedHeadlineFont} darkMode={darkMode} />
</div>
<div style={{ marginBottom: "20px" }}>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.bodyFont}</label>
<FontPicker value={selectedBodyFont} onChange={setSelectedBodyFont} darkMode={darkMode} />
</div>
<div style={{ marginBottom: "20px" }}>
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "10px" }}>{t.fontSize}</label>
<div style={{ display: "flex", gap: "8px" }}>
{(["S", "M", "L"] as const).map((size) => (
<button
key={size}
onClick={() => setSelectedFontSize(size)}
style={{
flex: 1, padding: "10px", borderRadius: "8px",
border: `2px solid ${selectedFontSize === size ? accentColor : borderColor}`,
background: selectedFontSize === size ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
color: textColor, fontSize: size === "S" ? "0.8rem" : size === "M" ? "0.95rem" : "1.1rem",
fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
}}
>
{size}
</button>
))}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderRadius: "10px", border: `1px solid ${borderColor}` }}>
<span style={{ fontSize: "0.95rem", fontWeight: 500, color: textColor }}>{t.darkMode}</span>
<button
onClick={onDarkModeToggle}
style={{
width: "44px", height: "26px", borderRadius: "13px", border: "none",
background: darkMode ? accentColor : "#d1d5db", cursor: "pointer",
position: "relative", transition: "background 0.2s",
}}
>
<div style={{
width: "20px", height: "20px", borderRadius: "50%",
background: "#fff", position: "absolute", top: "3px",
left: darkMode ? "21px" : "3px", transition: "left 0.2s",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{darkMode ? <Moon size={12} color="#333" /> : <Sun size={12} color="#333" />}
</div>
</button>
</div>
</div>
);
}
return null;
};
return (
<div className="onboarding-overlay">
<div className="onboarding-card" style={{ background: bg, color: textColor }}>
{/* Close button */}
{currentStep > 0 && (
<button
onClick={onSkip}
style={{
position: "absolute", top: "12px", right: "12px", background: "none", border: "none",
color: mutedColor, cursor: "pointer", padding: "4px", zIndex: 10,
}}
title="Close"
>
<X size={18} />
</button>
)}
{/* Step content with animation */}
<div
key={animKey}
className={direction === "forward" ? "onboarding-step-forward" : "onboarding-step-backward"}
>
{renderStep()}
</div>
{/* Footer with progress + nav (not on welcome) */}
{currentStep > 0 && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "16px 30px 24px", borderTop: `1px solid ${borderColor}`,
}}>
<button onClick={goBack} style={secondaryBtn}>
<ChevronLeft size={16} /> {t.back}
</button>
{/* Progress dots */}
<div style={{ display: "flex", gap: "6px" }}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
width: i === currentStep ? "20px" : "8px", height: "8px",
borderRadius: "4px", transition: "all 0.3s",
background: i === currentStep ? accentColor : i < currentStep ? accentColor : (darkMode ? "#4b5563" : "#d1d5db"),
opacity: i < currentStep ? 0.5 : 1,
}}
/>
))}
</div>
{isLastStep ? (
<button onClick={handleFinish} style={primaryBtn}>
{t.finish}
</button>
) : (
<button onClick={goNext} style={primaryBtn}>
{t.next} <ChevronRight size={16} />
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.7, pointerEvents: "none", zIndex: 1, lineHeight: 1, color: darkMode ? "#aaa" : "#555" }}>
<div className="weather-chip" style={{ position: "absolute", top: "2px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", pointerEvents: "none", zIndex: 20, lineHeight: 1, color: darkMode ? "#bbb" : "#555", background: darkMode ? "rgba(17,17,17,0.9)" : "rgba(255,255,255,0.92)", padding: "1px 5px", borderRadius: "6px", backdropFilter: "blur(4px)", boxShadow: "0 0 0 1px " + (darkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)") }}>
{d.includes("icon") && <span>{getWeatherIcon(w.code)}</span>}
{parts.length > 0 && <span style={{ fontWeight: 600 }}>{parts.join(" ")}</span>}
</div>
@ -8879,6 +8890,21 @@ export default function WeeklyView() {
)
}
{/* Onboarding Wizard */}
{showOnboarding && (
<OnboardingWizard
profile={profile}
darkMode={darkMode}
language={language}
connections={connections}
onComplete={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
saveSetting={saveSetting}
onLanguageChange={(lang: string) => { setLanguage(lang); }}
onDarkModeToggle={() => setDarkMode(!darkMode)}
/>
)}
{
selectedTaskForNotes && (
<NotesSidebar
@ -9725,7 +9751,7 @@ function TaskItem({
<span
className="flex-shrink-0"
title={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, paddingLeft: "4px" }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
</span>

View File

@ -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
};