From 174c7f3e0e3b95a15f70f0eb5af8da217e72d923 Mon Sep 17 00:00:00 2001 From: mARTin Date: Fri, 20 Feb 2026 17:06:38 +0100 Subject: [PATCH] feat: Goal persistence, font settings, z-index fix, task sync, and scope fix - Save edited quotes as weekly goals on blur (header + settings panel) - Fresh quote shown when navigating between weeks (no stale cache) - Add goal font family, size, and weight settings with UI controls - Lower calendar event z-index so tasks are always draggable on top - Set line-height: initial on task items for consistent rendering - Fix Google Tasks auth scope from tasks.readonly to tasks (read/write) - Add Google Tasks update/delete functions for bidirectional sync - Sync task renames and deletes back to Google Tasks - Add goal font fields to Prisma schema and profile API Co-Authored-By: Claude Opus 4.6 --- .../migration.sql | 4 + prisma/schema.prisma | 3 + src/app/api/calendar/google/start/route.ts | 2 +- src/app/api/tasks/sync/route.ts | 76 ++++++++----- src/app/api/user/profile/route.ts | 12 +- src/app/globals.css | 3 + src/components/WeeklyView.tsx | 106 +++++++++++++++++- src/lib/google-tasks.ts | 43 +++++++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 prisma/migrations/20260220_add_goal_font_settings/migration.sql diff --git a/prisma/migrations/20260220_add_goal_font_settings/migration.sql b/prisma/migrations/20260220_add_goal_font_settings/migration.sql new file mode 100644 index 0000000..8e2d06c --- /dev/null +++ b/prisma/migrations/20260220_add_goal_font_settings/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "goalFontFamily" TEXT DEFAULT 'Inter'; +ALTER TABLE "User" ADD COLUMN "goalFontSize" TEXT DEFAULT '0.9rem'; +ALTER TABLE "User" ADD COLUMN "goalFontWeight" TEXT DEFAULT '500'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d399681..7c57470 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,9 @@ model User { fontSize String @default("M") // "S", "M", "L" goalFallbackType String @default("quote") // "quote" | "next_todo" | "default" goalDefaultSentence String @default("goal of the week") + goalFontFamily String? @default("Inter") + goalFontSize String? @default("0.9rem") + goalFontWeight String? @default("500") headlineFont String @default("Inter") headlineFontSize String? @default("1.25rem") headlineFontWeight String? @default("900") diff --git a/src/app/api/calendar/google/start/route.ts b/src/app/api/calendar/google/start/route.ts index cce4f0b..8a29e30 100644 --- a/src/app/api/calendar/google/start/route.ts +++ b/src/app/api/calendar/google/start/route.ts @@ -36,7 +36,7 @@ export async function GET(request: NextRequest) { scope: [ 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events', - 'https://www.googleapis.com/auth/tasks.readonly', + 'https://www.googleapis.com/auth/tasks', ], prompt: 'consent', state: session.user.email, // Pass user email to identify in callback diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index c2ddd8c..4426616 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; -import { createGoogleClient, updateGoogleTaskStatus } from '@/lib/google-tasks'; +import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask } from '@/lib/google-tasks'; import { updateTaskStatus } from '@/lib/apple-calendar'; const prisma = new PrismaClient(); @@ -15,7 +15,7 @@ export async function PATCH(req: NextRequest) { } const body = await req.json(); - const { taskId, completed } = body; + const { taskId, completed, title, action } = body; if (!taskId) { return NextResponse.json({ error: 'Task ID required' }, { status: 400 }); @@ -44,29 +44,47 @@ export async function PATCH(req: NextRequest) { if (account && account.access_token) { const client = createGoogleClient(account.access_token, account.refresh_token || undefined); - await updateGoogleTaskStatus( - client, - task.externalListId, - task.externalId, - completed ? 'completed' : 'needsAction' - ); + + if (action === 'delete') { + await deleteGoogleTask(client, task.externalListId, task.externalId); + } else if (title !== undefined && completed !== undefined) { + await updateGoogleTask(client, task.externalListId, task.externalId, { + title, + status: completed ? 'completed' : 'needsAction', + }); + } else if (title !== undefined) { + await updateGoogleTask(client, task.externalListId, task.externalId, { title }); + } else if (completed !== undefined) { + await updateGoogleTaskStatus( + client, + task.externalListId, + task.externalId, + completed ? 'completed' : 'needsAction' + ); + } } } else if (task.externalProvider === 'apple' && task.externalListId) { - // Look for apple-reminders connection for task sync const connection = await prisma.calendarConnection.findFirst({ where: { userId: task.userId, provider: 'apple-reminders' } }); if (connection) { - const [email, password] = connection.accessToken.split(':'); - await updateTaskStatus( - email, - password, - task.externalListId, // In our logic, externalListId is the calendar URL - task.externalId, - completed - ); + const colonIdx = connection.accessToken.indexOf(':'); + const email = connection.accessToken.slice(0, colonIdx); + const password = connection.accessToken.slice(colonIdx + 1); + + if (completed !== undefined) { + await updateTaskStatus( + email, + password, + task.externalListId, + task.externalId, + completed + ); + } + // Note: Apple Reminders title update and delete via CloudKit + // is not currently supported due to API limitations } } @@ -77,20 +95,20 @@ export async function PATCH(req: NextRequest) { }); } - // We also update the local task status if it wasn't already updated by the frontend calling simple toggle - // But usually frontend updates local state then calls this. - // Let's assume this endpoint is purely for triggering the sync side-effect or ensuring consistency. - // Actually, strictly speaking, this endpoint is 'sync'. It should probably update the local task too if not done. - // But the frontend usually calls `updateTask` (PUT/PATCH /api/tasks/id) for local updates. - // Let's assume the frontend calls this *in addition* or we bundle it. - // For now, let's explicitely update local state here too to be safe/sure. + // Update local task state + const updateData: any = {}; + if (completed !== undefined) updateData.completed = completed; + if (title !== undefined) updateData.title = title; - const updatedTask = await prisma.task.update({ - where: { id: taskId }, - data: { completed } // Ensure local db matches intent - }); + if (Object.keys(updateData).length > 0) { + const updatedTask = await prisma.task.update({ + where: { id: taskId }, + data: updateData + }); + return NextResponse.json({ success: true, task: updatedTask }); + } - return NextResponse.json({ success: true, task: updatedTask }); + return NextResponse.json({ success: true }); } catch (error: unknown) { console.error('Sync error:', error); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index ba90d5b..1df9f43 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -36,6 +36,9 @@ export async function GET(request: NextRequest) { fontSize: true, goalFallbackType: true, goalDefaultSentence: true, + goalFontFamily: true, + goalFontSize: true, + goalFontWeight: true, headlineFont: true, headlineFontSize: true, headlineFontWeight: true, @@ -94,7 +97,8 @@ export async function PATCH(request: NextRequest) { eventFontFamily, eventFontSize, eventFontWeight, fontWeight, weekendColorSat, weekendColorSun, weekdayColor, dateColor, taskColor, todayHighlightColor, - pastDayColor, goalFallbackType, goalDefaultSentence + pastDayColor, goalFallbackType, goalDefaultSentence, + goalFontFamily, goalFontSize, goalFontWeight } = body; const updateData: any = { @@ -145,6 +149,9 @@ export async function PATCH(request: NextRequest) { ...(pastDayColor !== undefined && { pastDayColor }), ...(goalFallbackType !== undefined && { goalFallbackType }), ...(goalDefaultSentence !== undefined && { goalDefaultSentence }), + ...(goalFontFamily !== undefined && { goalFontFamily }), + ...(goalFontSize !== undefined && { goalFontSize }), + ...(goalFontWeight !== undefined && { goalFontWeight }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -203,6 +210,9 @@ export async function PATCH(request: NextRequest) { pastDayColor: true, goalFallbackType: true, goalDefaultSentence: true, + goalFontFamily: true, + goalFontSize: true, + goalFontWeight: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index b1938b7..1869280 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -709,6 +709,7 @@ h3 { position: relative; transition: all 0.2s ease; border-bottom: 1px solid var(--weekly-border); /* Restore lines */ + line-height: initial; } /* Remove border from last item to look cleaner, or keep for paper look */ @@ -1630,8 +1631,10 @@ h3 { text-overflow: ellipsis; transition: color 0.15s ease; position: relative; + z-index: 5; /* Weekly-style: clean text, no boxes */ color: var(--weekly-text); + line-height: initial; } .time-slot-task:hover { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index b6eb7cb..4aeb6c5 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -455,6 +455,9 @@ export default function WeeklyView() { pastDayColor?: string; goalFallbackType?: 'quote' | 'next_todo' | 'default'; goalDefaultSentence?: string; + goalFontFamily?: string; + goalFontSize?: string; + goalFontWeight?: string; }>({ name: session?.user?.name || '', email: session?.user?.email || '', @@ -491,6 +494,9 @@ export default function WeeklyView() { eventFontSize: '0.85rem', eventFontWeight: '400', fontWeight: '400', + goalFontFamily: 'Inter', + goalFontSize: '0.9rem', + goalFontWeight: '500', weekendColorSat: '#666666', weekendColorSun: '#dc2626', focusTimerDuration: 25, @@ -1572,6 +1578,16 @@ export default function WeeklyView() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, title: newTitle.trim() }), }); + + // Sync title change to external provider if applicable + const task = tasks.find(t => t.id === taskId); + if (task?.externalId && task?.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, title: newTitle.trim() }), + }).catch(e => console.error('Sync error:', e)); + } } catch (error) { console.error('Error updating task:', error); } @@ -1727,11 +1743,20 @@ export default function WeeklyView() { setEditingTaskId(null); try { + // Sync delete to external provider if applicable + const origTask = tasks.find(t => t.id === originalId); + if (origTask?.externalId && origTask?.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId: originalId, action: 'delete' }), + }).catch(e => console.error('Sync delete error:', e)); + } + // Deleting the original ID stops the series await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' }); } catch (error) { console.error('Error deleting series:', error); - // Optionally revert UI state here if needed, but for now assuming success } return; } @@ -1742,6 +1767,15 @@ export default function WeeklyView() { setEditingTaskId(null); try { + // Sync delete to external provider if applicable + if (taskToDelete?.externalId && taskToDelete?.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, action: 'delete' }), + }).catch(e => console.error('Sync delete error:', e)); + } + await fetch(`/api/tasks?id=${taskId}`, { method: 'DELETE' }); } catch (error) { console.error('Error deleting task:', error); @@ -2184,17 +2218,27 @@ export default function WeeklyView() { type="text" value={goal} onChange={(e) => setGoal(e.target.value)} - onBlur={() => setIsEditingGoal(false)} + onBlur={() => { saveGoal(goal); setIsEditingGoal(false); }} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} autoFocus className="border-b border-gray-300 focus:outline-none focus:border-black px-1 text-center font-medium italic" - style={{ width: `${Math.max(10, goal.length)}ch` }} + style={{ + width: `${Math.max(10, goal.length)}ch`, + fontFamily: profile.goalFontFamily ? `"${profile.goalFontFamily}", sans-serif` : undefined, + fontSize: profile.goalFontSize || undefined, + fontWeight: profile.goalFontWeight || undefined, + }} /> ) : ( !showNextTask && setIsEditingGoal(true)} className={`cursor-pointer font-medium italic text-gray-600 hover:text-black transition-colors ${showNextTask ? 'cursor-default' : ''}`} title={showNextTask ? "Next task" : "Edit goal"} + style={{ + fontFamily: profile.goalFontFamily ? `"${profile.goalFontFamily}", sans-serif` : undefined, + fontSize: profile.goalFontSize || undefined, + fontWeight: profile.goalFontWeight || undefined, + }} > {showNextTask ? (() => { const today = new Date(); @@ -2442,7 +2486,7 @@ export default function WeeklyView() { right: 0, height: `${height}px`, zIndex: 1, - pointerEvents: 'none' + pointerEvents: 'none', }} >