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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-20 17:06:38 +01:00
parent 798bcfe44a
commit 174c7f3e0e
9 changed files with 214 additions and 37 deletions

View File

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

View File

@ -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")

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}}
/>
) : (
<span
onClick={() => !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',
}}
>
<button
@ -2660,7 +2704,7 @@ export default function WeeklyView() {
top: `${topOffset}px`,
left: '-10px',
right: '-15px',
zIndex: 5,
zIndex: 1,
flexDirection: 'column',
alignItems: 'flex-start',
backgroundColor: bgColor,
@ -4205,6 +4249,9 @@ function SettingsSidebar({
pastDayColor?: string;
goalFallbackType?: 'quote' | 'next_todo' | 'default';
goalDefaultSentence?: string;
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
}>({
name: '',
email: '',
@ -4240,6 +4287,9 @@ function SettingsSidebar({
taskFontSize: '0.9rem',
taskFontWeight: '400',
fontWeight: '400',
goalFontFamily: 'Inter',
goalFontSize: '0.9rem',
goalFontWeight: '500',
weekendColorSat: '#666666',
weekendColorSun: '#dc2626',
weekdayColor: '#888888',
@ -4310,6 +4360,9 @@ function SettingsSidebar({
eventFontFamily: data.user.eventFontFamily || 'Inter',
eventFontSize: data.user.eventFontSize || '0.85rem',
eventFontWeight: data.user.eventFontWeight || '400',
goalFontFamily: data.user.goalFontFamily || 'Inter',
goalFontSize: data.user.goalFontSize || '0.9rem',
goalFontWeight: data.user.goalFontWeight || '500',
weekendColorSat: data.user.weekendColorSat || '#666666',
weekendColorSun: data.user.weekendColorSun || '#dc2626',
weekdayColor: data.user.weekdayColor || '#888888',
@ -4672,6 +4725,8 @@ function SettingsSidebar({
type="text"
value={goal}
onChange={(e) => setGoal(e.target.value)}
onBlur={() => saveGoal(goal)}
onKeyDown={(e) => e.key === 'Enter' && (e.currentTarget.blur())}
className="weekly-input"
placeholder={t.goalOfWeek}
style={{ width: '100%' }}
@ -5079,6 +5134,47 @@ function SettingsSidebar({
</div>
</div>
{/* Goal Font */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Goal Font</label>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
<select
value={profile.goalFontFamily || 'Inter'}
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
value={profile.goalFontSize || '0.9rem'}
onChange={(e) => setProfile({ ...profile, goalFontSize: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Normal 14px</option>
<option value="0.9rem">Default 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.1rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
</select>
<select
value={profile.goalFontWeight || '500'}
onChange={(e) => setProfile({ ...profile, goalFontWeight: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
</select>
</div>
</div>
{/* Element Colors */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>

View File

@ -79,6 +79,49 @@ export const fetchGoogleTasks = async (client: OAuth2Client, taskListId: string)
/**
* Update a Google Task status
*/
export const updateGoogleTask = async (client: OAuth2Client, taskListId: string, taskId: string, updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed' }): Promise<GoogleTask> => {
const service = google.tasks({ version: 'v1', auth: client });
try {
const requestBody: any = {};
if (updates.title !== undefined) requestBody.title = updates.title;
if (updates.notes !== undefined) requestBody.notes = updates.notes;
if (updates.status !== undefined) {
requestBody.status = updates.status;
requestBody.completed = updates.status === 'completed' ? new Date().toISOString() : null;
}
const response = await service.tasks.patch({
tasklist: taskListId,
task: taskId,
requestBody,
});
const item = response.data;
return {
id: item.id!,
title: item.title!,
notes: item.notes || undefined,
status: item.status!,
due: item.due || undefined,
updated: item.updated!
};
} catch (error) {
console.error(`Error updating Google Task ${taskId}:`, error);
throw error;
}
};
export const deleteGoogleTask = async (client: OAuth2Client, taskListId: string, taskId: string): Promise<void> => {
const service = google.tasks({ version: 'v1', auth: client });
try {
await service.tasks.delete({
tasklist: taskListId,
task: taskId,
});
} catch (error) {
console.error(`Error deleting Google Task ${taskId}:`, error);
throw error;
}
};
export const updateGoogleTaskStatus = async (client: OAuth2Client, taskListId: string, taskId: string, status: 'needsAction' | 'completed'): Promise<GoogleTask> => {
const service = google.tasks({ version: 'v1', auth: client });
try {

File diff suppressed because one or more lines are too long