feat: account number display, weekly goals export/import, calendar event fixes
- Show Account Number in Settings > Account for user identification - Add weekly goals to JSON export/import with upsert-based merge - Fix calendar events not appearing instantly (await cache ops, prevent provider force-refresh from overwriting optimistic updates) - Add Sign Out button in Settings > Account for mobile accessibility - Fix dateVerticalAlign persistence in profile API - Fix quote API error handling for non-JSON responses - Add Synology to CalendarEvent source type union v1.20.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9863f8e28e
commit
4c18d37bec
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.19.0",
|
"version": "1.20.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -75,6 +75,7 @@ model User {
|
|||||||
dateLayout String @default("right")
|
dateLayout String @default("right")
|
||||||
mobileDateLayout String @default("below")
|
mobileDateLayout String @default("below")
|
||||||
dateAlignment String @default("center")
|
dateAlignment String @default("center")
|
||||||
|
dateVerticalAlign String? @default("middle")
|
||||||
hourLabelFormat String @default("short")
|
hourLabelFormat String @default("short")
|
||||||
showSubHourSlots Boolean @default(true)
|
showSubHourSlots Boolean @default(true)
|
||||||
allDayPosition String @default("above")
|
allDayPosition String @default("above")
|
||||||
|
|||||||
@ -65,9 +65,12 @@ export async function POST(request: NextRequest) {
|
|||||||
url,
|
url,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Update cache
|
// Update cache - await to ensure it's ready before client refreshes
|
||||||
upsertCachedEvent(userId, connection.id, connection.provider, event)
|
try {
|
||||||
.catch(e => console.error('[CACHE] Failed to cache created event:', e));
|
await upsertCachedEvent(userId, connection.id, connection.provider, event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CACHE] Failed to cache created event:', e);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ event });
|
return NextResponse.json({ event });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -109,9 +112,12 @@ export async function PATCH(request: NextRequest) {
|
|||||||
url,
|
url,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Update cache
|
// Update cache - await to ensure it's ready before client refreshes
|
||||||
upsertCachedEvent(userId, connection.id, connection.provider, event)
|
try {
|
||||||
.catch(e => console.error('[CACHE] Failed to cache updated event:', e));
|
await upsertCachedEvent(userId, connection.id, connection.provider, event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CACHE] Failed to cache updated event:', e);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ event });
|
return NextResponse.json({ event });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -145,9 +151,12 @@ export async function DELETE(request: NextRequest) {
|
|||||||
|
|
||||||
await deleteCalendarEvent(connection, calendarId, eventId);
|
await deleteCalendarEvent(connection, calendarId, eventId);
|
||||||
|
|
||||||
// Remove from cache
|
// Remove from cache - await to ensure consistency
|
||||||
deleteCachedEvent(userId, eventId, connection.provider)
|
try {
|
||||||
.catch(e => console.error('[CACHE] Failed to delete cached event:', e));
|
await deleteCachedEvent(userId, eventId, connection.provider);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CACHE] Failed to delete cached event:', e);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -99,12 +99,26 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch weekly goals
|
||||||
|
const weeklyGoals = await prisma.weeklyGoal.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { weekStart: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
weekStart: true,
|
||||||
|
text: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const exportData = {
|
const exportData = {
|
||||||
exportVersion: 1,
|
exportVersion: 1,
|
||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
somedayLists,
|
somedayLists,
|
||||||
projects,
|
projects,
|
||||||
tasks: topLevelTasks,
|
tasks: topLevelTasks,
|
||||||
|
weeklyGoals,
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonContent = JSON.stringify(exportData, null, 2);
|
const jsonContent = JSON.stringify(exportData, null, 2);
|
||||||
|
|||||||
@ -30,6 +30,14 @@ interface ImportTask {
|
|||||||
subTasks?: ImportTask[];
|
subTasks?: ImportTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportWeeklyGoal {
|
||||||
|
id?: string;
|
||||||
|
weekStart: string;
|
||||||
|
text: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ImportData {
|
interface ImportData {
|
||||||
exportVersion: number;
|
exportVersion: number;
|
||||||
somedayLists?: Array<{
|
somedayLists?: Array<{
|
||||||
@ -50,6 +58,7 @@ interface ImportData {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}>;
|
}>;
|
||||||
tasks?: ImportTask[];
|
tasks?: ImportTask[];
|
||||||
|
weeklyGoals?: ImportWeeklyGoal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -81,6 +90,7 @@ export async function POST(request: Request) {
|
|||||||
const somedayLists = body.somedayLists || [];
|
const somedayLists = body.somedayLists || [];
|
||||||
const projects = body.projects || [];
|
const projects = body.projects || [];
|
||||||
const tasks = body.tasks || [];
|
const tasks = body.tasks || [];
|
||||||
|
const weeklyGoals = body.weeklyGoals || [];
|
||||||
|
|
||||||
// ID mapping: old export ID -> new DB ID
|
// ID mapping: old export ID -> new DB ID
|
||||||
const listIdMap = new Map<string, string>();
|
const listIdMap = new Map<string, string>();
|
||||||
@ -90,12 +100,14 @@ export async function POST(request: Request) {
|
|||||||
let importedLists = 0;
|
let importedLists = 0;
|
||||||
let importedProjects = 0;
|
let importedProjects = 0;
|
||||||
let importedTasks = 0;
|
let importedTasks = 0;
|
||||||
|
let importedGoals = 0;
|
||||||
|
|
||||||
// In replace mode, delete all existing data first
|
// In replace mode, delete all existing data first
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
await prisma.task.deleteMany({ where: { userId: user.id } });
|
await prisma.task.deleteMany({ where: { userId: user.id } });
|
||||||
await prisma.somedayList.deleteMany({ where: { userId: user.id } });
|
await prisma.somedayList.deleteMany({ where: { userId: user.id } });
|
||||||
await prisma.project.deleteMany({ where: { userId: user.id } });
|
await prisma.project.deleteMany({ where: { userId: user.id } });
|
||||||
|
await prisma.weeklyGoal.deleteMany({ where: { userId: user.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Import someday lists
|
// 1. Import someday lists
|
||||||
@ -196,6 +208,28 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Import weekly goals (upsert by weekStart to avoid duplicates)
|
||||||
|
for (const goal of weeklyGoals) {
|
||||||
|
const weekStart = new Date(goal.weekStart);
|
||||||
|
await prisma.weeklyGoal.upsert({
|
||||||
|
where: {
|
||||||
|
userId_weekStart: {
|
||||||
|
userId: user.id,
|
||||||
|
weekStart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
text: goal.text,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
weekStart,
|
||||||
|
text: goal.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
importedGoals++;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
mode,
|
mode,
|
||||||
@ -203,6 +237,7 @@ export async function POST(request: Request) {
|
|||||||
somedayLists: importedLists,
|
somedayLists: importedLists,
|
||||||
projects: importedProjects,
|
projects: importedProjects,
|
||||||
tasks: importedTasks,
|
tasks: importedTasks,
|
||||||
|
weeklyGoals: importedGoals,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export async function GET(request: NextRequest) {
|
|||||||
goalScope: true,
|
goalScope: true,
|
||||||
dateLayout: true,
|
dateLayout: true,
|
||||||
mobileDateLayout: true,
|
mobileDateLayout: true,
|
||||||
|
dateVerticalAlign: true,
|
||||||
headlineFont: true,
|
headlineFont: true,
|
||||||
headlineFontSize: true,
|
headlineFontSize: true,
|
||||||
headlineFontWeight: true,
|
headlineFontWeight: true,
|
||||||
@ -124,7 +125,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
weekdayColor, dateColor, taskColor, todayHighlightColor,
|
weekdayColor, dateColor, taskColor, todayHighlightColor,
|
||||||
pastDayColor, goalFallbackType, goalDefaultSentence,
|
pastDayColor, goalFallbackType, goalDefaultSentence,
|
||||||
goalFontFamily, goalFontSize, goalFontWeight, goalScope,
|
goalFontFamily, goalFontSize, goalFontWeight, goalScope,
|
||||||
dateLayout, mobileDateLayout,
|
dateLayout, mobileDateLayout, dateVerticalAlign,
|
||||||
hourLabelFormat, showSubHourSlots, allDayPosition,
|
hourLabelFormat, showSubHourSlots, allDayPosition,
|
||||||
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
@ -187,6 +188,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(goalScope !== undefined && { goalScope }),
|
...(goalScope !== undefined && { goalScope }),
|
||||||
...(dateLayout !== undefined && { dateLayout }),
|
...(dateLayout !== undefined && { dateLayout }),
|
||||||
...(mobileDateLayout !== undefined && { mobileDateLayout }),
|
...(mobileDateLayout !== undefined && { mobileDateLayout }),
|
||||||
|
...(dateVerticalAlign !== undefined && { dateVerticalAlign }),
|
||||||
...(hourLabelFormat !== undefined && { hourLabelFormat }),
|
...(hourLabelFormat !== undefined && { hourLabelFormat }),
|
||||||
...(showSubHourSlots !== undefined && { showSubHourSlots }),
|
...(showSubHourSlots !== undefined && { showSubHourSlots }),
|
||||||
...(allDayPosition !== undefined && { allDayPosition }),
|
...(allDayPosition !== undefined && { allDayPosition }),
|
||||||
@ -274,6 +276,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
goalScope: true,
|
goalScope: true,
|
||||||
dateLayout: true,
|
dateLayout: true,
|
||||||
mobileDateLayout: true,
|
mobileDateLayout: true,
|
||||||
|
dateVerticalAlign: true,
|
||||||
cwFontFamily: true,
|
cwFontFamily: true,
|
||||||
cwFontSize: true,
|
cwFontSize: true,
|
||||||
cwFontWeight: true,
|
cwFontWeight: true,
|
||||||
|
|||||||
@ -133,7 +133,7 @@ interface CalendarEvent {
|
|||||||
title: string;
|
title: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
source: "google" | "apple" | "outlook";
|
source: "google" | "apple" | "outlook" | "synology";
|
||||||
calendarId?: string;
|
calendarId?: string;
|
||||||
calendarTitle?: string;
|
calendarTitle?: string;
|
||||||
calendarColor?: string;
|
calendarColor?: string;
|
||||||
@ -818,6 +818,8 @@ export default function WeeklyView() {
|
|||||||
showTaskCheckboxes?: boolean;
|
showTaskCheckboxes?: boolean;
|
||||||
quoteSourceUrls?: string[];
|
quoteSourceUrls?: string[];
|
||||||
startDayOffset?: number;
|
startDayOffset?: number;
|
||||||
|
id?: string;
|
||||||
|
accountNumber?: number;
|
||||||
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
||||||
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
||||||
customWeekdayNames?: string;
|
customWeekdayNames?: string;
|
||||||
@ -1231,9 +1233,10 @@ export default function WeeklyView() {
|
|||||||
return [...prev, frontendEvent];
|
return [...prev, frontendEvent];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Delay the force-refresh to give the provider time to propagate
|
// Re-read from cache (not a force-refresh from provider, which could
|
||||||
// This prevents overwriting the optimistic update with stale data
|
// overwrite the optimistic update if the provider hasn't propagated yet).
|
||||||
setTimeout(() => fetchCalendarEvents(true), 3000);
|
// The backend already cached the event via upsertCachedEvent.
|
||||||
|
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error saving event:", error);
|
console.error("Error saving event:", error);
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
@ -1259,9 +1262,9 @@ export default function WeeklyView() {
|
|||||||
throw new Error(err.error || "Failed to delete event");
|
throw new Error(err.error || "Failed to delete event");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistically remove, then force refresh
|
// Optimistically remove, then re-read cache
|
||||||
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
|
||||||
fetchCalendarEvents(true);
|
setTimeout(() => fetchCalendarEvents(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting event:", error);
|
console.error("Error deleting event:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -1300,13 +1303,15 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
|
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
|
||||||
? profile.quoteSourceUrls
|
? profile.quoteSourceUrls
|
||||||
: [profile.quoteSourceUrl || "https://recite.vercel.app/api/random"];
|
: profile.quoteSourceUrl ? [profile.quoteSourceUrl] : [];
|
||||||
|
|
||||||
// Strategy: try sources until one works
|
// Strategy: try sources until one works
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
|
const contentType = res.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("application/json")) continue;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
let quoteText = "";
|
let quoteText = "";
|
||||||
@ -8105,6 +8110,7 @@ function SettingsSidebar({
|
|||||||
quoteSourceUrls?: string[];
|
quoteSourceUrls?: string[];
|
||||||
startDayOffset?: number;
|
startDayOffset?: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
accountNumber?: number;
|
||||||
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
weekdayFormat?: "long" | "short" | "narrow" | "custom";
|
||||||
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
weekdayCase?: "normal" | "capitalize" | "uppercase";
|
||||||
customWeekdayNames?: string;
|
customWeekdayNames?: string;
|
||||||
@ -11643,6 +11649,43 @@ function SettingsSidebar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{profile.accountNumber && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.language === "de" ? "Kontonummer" : "Account Number"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`#${profile.accountNumber}`}
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
className="weekly-input"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
border: "1px solid #eee",
|
||||||
|
borderRadius: "4px",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
color: "#555",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
cursor: "text",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
|
||||||
|
{profile.language === "de"
|
||||||
|
? "Ihre Kontonummer zur Identifikation bei E-Mail-Änderungen"
|
||||||
|
: "Your account number for identification when changing email"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
@ -12099,6 +12142,34 @@ function SettingsSidebar({
|
|||||||
borderTop: "1px solid var(--weekly-border)",
|
borderTop: "1px solid var(--weekly-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Sign Out Button - accessible on mobile */}
|
||||||
|
<button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="weekly-auth-button w-full justify-center"
|
||||||
|
style={{
|
||||||
|
marginBottom: "18px",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid var(--weekly-settings-input-border)",
|
||||||
|
background: "var(--weekly-settings-item-bg)",
|
||||||
|
color: "var(--weekly-settings-text)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
{t.signOut}
|
||||||
|
</button>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
style={{
|
style={{
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user