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:
mARTin 2026-03-08 19:56:21 +01:00
parent 9863f8e28e
commit 4c18d37bec
7 changed files with 151 additions and 18 deletions

View File

@ -1,6 +1,6 @@
{
"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",
"main": "index.js",
"scripts": {

View File

@ -75,6 +75,7 @@ model User {
dateLayout String @default("right")
mobileDateLayout String @default("below")
dateAlignment String @default("center")
dateVerticalAlign String? @default("middle")
hourLabelFormat String @default("short")
showSubHourSlots Boolean @default(true)
allDayPosition String @default("above")

View File

@ -65,9 +65,12 @@ export async function POST(request: NextRequest) {
url,
} as any);
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache created event:', e));
// Update cache - await to ensure it's ready before client refreshes
try {
await upsertCachedEvent(userId, connection.id, connection.provider, event);
} catch (e) {
console.error('[CACHE] Failed to cache created event:', e);
}
return NextResponse.json({ event });
} catch (error: any) {
@ -109,9 +112,12 @@ export async function PATCH(request: NextRequest) {
url,
} as any);
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache updated event:', e));
// Update cache - await to ensure it's ready before client refreshes
try {
await upsertCachedEvent(userId, connection.id, connection.provider, event);
} catch (e) {
console.error('[CACHE] Failed to cache updated event:', e);
}
return NextResponse.json({ event });
} catch (error: any) {
@ -145,9 +151,12 @@ export async function DELETE(request: NextRequest) {
await deleteCalendarEvent(connection, calendarId, eventId);
// Remove from cache
deleteCachedEvent(userId, eventId, connection.provider)
.catch(e => console.error('[CACHE] Failed to delete cached event:', e));
// Remove from cache - await to ensure consistency
try {
await deleteCachedEvent(userId, eventId, connection.provider);
} catch (e) {
console.error('[CACHE] Failed to delete cached event:', e);
}
return NextResponse.json({ success: true });
} catch (error: any) {

View File

@ -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 = {
exportVersion: 1,
exportDate: new Date().toISOString(),
somedayLists,
projects,
tasks: topLevelTasks,
weeklyGoals,
};
const jsonContent = JSON.stringify(exportData, null, 2);

View File

@ -30,6 +30,14 @@ interface ImportTask {
subTasks?: ImportTask[];
}
interface ImportWeeklyGoal {
id?: string;
weekStart: string;
text: string;
createdAt?: string;
updatedAt?: string;
}
interface ImportData {
exportVersion: number;
somedayLists?: Array<{
@ -50,6 +58,7 @@ interface ImportData {
updatedAt?: string;
}>;
tasks?: ImportTask[];
weeklyGoals?: ImportWeeklyGoal[];
}
export async function POST(request: Request) {
@ -81,6 +90,7 @@ export async function POST(request: Request) {
const somedayLists = body.somedayLists || [];
const projects = body.projects || [];
const tasks = body.tasks || [];
const weeklyGoals = body.weeklyGoals || [];
// ID mapping: old export ID -> new DB ID
const listIdMap = new Map<string, string>();
@ -90,12 +100,14 @@ export async function POST(request: Request) {
let importedLists = 0;
let importedProjects = 0;
let importedTasks = 0;
let importedGoals = 0;
// In replace mode, delete all existing data first
if (mode === 'replace') {
await prisma.task.deleteMany({ where: { userId: user.id } });
await prisma.somedayList.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
@ -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({
success: true,
mode,
@ -203,6 +237,7 @@ export async function POST(request: Request) {
somedayLists: importedLists,
projects: importedProjects,
tasks: importedTasks,
weeklyGoals: importedGoals,
},
});
} catch (error) {

View File

@ -44,6 +44,7 @@ export async function GET(request: NextRequest) {
goalScope: true,
dateLayout: true,
mobileDateLayout: true,
dateVerticalAlign: true,
headlineFont: true,
headlineFontSize: true,
headlineFontWeight: true,
@ -124,7 +125,7 @@ export async function PATCH(request: NextRequest) {
weekdayColor, dateColor, taskColor, todayHighlightColor,
pastDayColor, goalFallbackType, goalDefaultSentence,
goalFontFamily, goalFontSize, goalFontWeight, goalScope,
dateLayout, mobileDateLayout,
dateLayout, mobileDateLayout, dateVerticalAlign,
hourLabelFormat, showSubHourSlots, allDayPosition,
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
@ -187,6 +188,7 @@ export async function PATCH(request: NextRequest) {
...(goalScope !== undefined && { goalScope }),
...(dateLayout !== undefined && { dateLayout }),
...(mobileDateLayout !== undefined && { mobileDateLayout }),
...(dateVerticalAlign !== undefined && { dateVerticalAlign }),
...(hourLabelFormat !== undefined && { hourLabelFormat }),
...(showSubHourSlots !== undefined && { showSubHourSlots }),
...(allDayPosition !== undefined && { allDayPosition }),
@ -274,6 +276,7 @@ export async function PATCH(request: NextRequest) {
goalScope: true,
dateLayout: true,
mobileDateLayout: true,
dateVerticalAlign: true,
cwFontFamily: true,
cwFontSize: true,
cwFontWeight: true,

View File

@ -133,7 +133,7 @@ interface CalendarEvent {
title: string;
startTime: string;
endTime: string;
source: "google" | "apple" | "outlook";
source: "google" | "apple" | "outlook" | "synology";
calendarId?: string;
calendarTitle?: string;
calendarColor?: string;
@ -818,6 +818,8 @@ export default function WeeklyView() {
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
startDayOffset?: number;
id?: string;
accountNumber?: number;
weekdayFormat?: "long" | "short" | "narrow" | "custom";
weekdayCase?: "normal" | "capitalize" | "uppercase";
customWeekdayNames?: string;
@ -1231,9 +1233,10 @@ export default function WeeklyView() {
return [...prev, frontendEvent];
});
}
// Delay the force-refresh to give the provider time to propagate
// This prevents overwriting the optimistic update with stale data
setTimeout(() => fetchCalendarEvents(true), 3000);
// Re-read from cache (not a force-refresh from provider, which could
// overwrite the optimistic update if the provider hasn't propagated yet).
// The backend already cached the event via upsertCachedEvent.
setTimeout(() => fetchCalendarEvents(false), 2000);
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
@ -1259,9 +1262,9 @@ export default function WeeklyView() {
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));
fetchCalendarEvents(true);
setTimeout(() => fetchCalendarEvents(false), 2000);
} catch (error) {
console.error("Error deleting event:", error);
throw error;
@ -1300,13 +1303,15 @@ export default function WeeklyView() {
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
? profile.quoteSourceUrls
: [profile.quoteSourceUrl || "https://recite.vercel.app/api/random"];
: profile.quoteSourceUrl ? [profile.quoteSourceUrl] : [];
// Strategy: try sources until one works
for (const url of urls) {
try {
const res = await fetch(url);
if (!res.ok) continue;
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("application/json")) continue;
const data = await res.json();
let quoteText = "";
@ -8105,6 +8110,7 @@ function SettingsSidebar({
quoteSourceUrls?: string[];
startDayOffset?: number;
id?: string;
accountNumber?: number;
weekdayFormat?: "long" | "short" | "narrow" | "custom";
weekdayCase?: "normal" | "capitalize" | "uppercase";
customWeekdayNames?: string;
@ -11643,6 +11649,43 @@ function SettingsSidebar({
</span>
</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>
<label
style={{
@ -12099,6 +12142,34 @@ function SettingsSidebar({
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
style={{
fontSize: "1rem",