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",
|
||||
"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": {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user