fix: tab persistence, dissolve tabs, and Synology CalDAV discovery fallback

- Fix someday tabs disappearing on reload (include tab field in fetch mapping)
- Add dissolve/delete tab feature with X button on hover (all 5 languages)
- Fix Synology calendar "cannot find homeUrl" by falling back to derived homeUrl
  when well-known CalDAV discovery fails

v1.25.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-10 21:51:22 +01:00
parent ff6ed1f949
commit b56bfdeb52
4 changed files with 170 additions and 33 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.25.1", "version": "1.25.2",
"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": {

View File

@ -1548,6 +1548,67 @@ h3 {
color: #fff; color: #fff;
} }
/* Someday Tab Wrappers with dissolve buttons */
.someday-tab-wrapper {
position: relative;
display: flex;
align-items: center;
}
.someday-tab-dissolve {
position: absolute;
right: -2px;
top: -2px;
background: var(--weekly-bg, #fff);
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 50%;
cursor: pointer;
color: var(--weekly-text-light, #999);
padding: 1px;
line-height: 0;
opacity: 0;
transition: opacity 0.15s;
}
.someday-tab-wrapper:hover .someday-tab-dissolve {
opacity: 1;
}
.someday-tab-dissolve:hover {
color: #ef4444;
border-color: #ef4444;
}
.someday-tab-wrapper-h {
position: relative;
display: inline-flex;
align-items: center;
}
.someday-tab-dissolve-h {
position: absolute;
right: -4px;
top: -4px;
background: var(--weekly-bg, #fff);
border: 1px solid var(--weekly-border, #e5e7eb);
border-radius: 50%;
cursor: pointer;
color: var(--weekly-text-light, #999);
padding: 1px;
line-height: 0;
opacity: 0;
transition: opacity 0.15s;
}
.someday-tab-wrapper-h:hover .someday-tab-dissolve-h {
opacity: 1;
}
.someday-tab-dissolve-h:hover {
color: #ef4444;
border-color: #ef4444;
}
/* Someday Tab Select (in list headers) */ /* Someday Tab Select (in list headers) */
.someday-tab-select { .someday-tab-select {
appearance: none; appearance: none;

View File

@ -261,6 +261,7 @@ const translations: Record<string, any> = {
newTabName: "New tab name:", newTabName: "New tab name:",
assignTab: "Assign to tab", assignTab: "Assign to tab",
renameTab: "Double-click to rename", renameTab: "Double-click to rename",
dissolveTab: "Remove tab (keep lists)",
loading: "Loading your tasks...", loading: "Loading your tasks...",
sycing: "Syncing...", sycing: "Syncing...",
synced: "Synced", synced: "Synced",
@ -448,6 +449,7 @@ const translations: Record<string, any> = {
newTabName: "Neuer Tab-Name:", newTabName: "Neuer Tab-Name:",
assignTab: "Tab zuweisen", assignTab: "Tab zuweisen",
renameTab: "Doppelklick zum Umbenennen", renameTab: "Doppelklick zum Umbenennen",
dissolveTab: "Tab entfernen (Listen behalten)",
loading: "Lade Aufgaben...", loading: "Lade Aufgaben...",
syncing: "Synchronisiere...", syncing: "Synchronisiere...",
synced: "Synchronisiert", synced: "Synchronisiert",
@ -634,6 +636,7 @@ const translations: Record<string, any> = {
newTabName: "Nom du nouvel onglet :", newTabName: "Nom du nouvel onglet :",
assignTab: "Assigner à un onglet", assignTab: "Assigner à un onglet",
renameTab: "Double-cliquez pour renommer", renameTab: "Double-cliquez pour renommer",
dissolveTab: "Supprimer l'onglet (garder les listes)",
loading: "Chargement de vos tâches…", loading: "Chargement de vos tâches…",
sycing: "Synchronisation…", sycing: "Synchronisation…",
synced: "Synchronisé", synced: "Synchronisé",
@ -820,6 +823,7 @@ const translations: Record<string, any> = {
newTabName: "Nombre de nueva pestaña:", newTabName: "Nombre de nueva pestaña:",
assignTab: "Asignar a pestaña", assignTab: "Asignar a pestaña",
renameTab: "Doble clic para renombrar", renameTab: "Doble clic para renombrar",
dissolveTab: "Eliminar pestaña (mantener listas)",
loading: "Cargando tus tareas…", loading: "Cargando tus tareas…",
sycing: "Sincronizando…", sycing: "Sincronizando…",
synced: "Sincronizado", synced: "Sincronizado",
@ -1006,6 +1010,7 @@ const translations: Record<string, any> = {
newTabName: "Nome nuova scheda:", newTabName: "Nome nuova scheda:",
assignTab: "Assegna a scheda", assignTab: "Assegna a scheda",
renameTab: "Doppio clic per rinominare", renameTab: "Doppio clic per rinominare",
dissolveTab: "Rimuovi scheda (mantieni liste)",
loading: "Caricamento delle attività…", loading: "Caricamento delle attività…",
sycing: "Sincronizzazione…", sycing: "Sincronizzazione…",
synced: "Sincronizzato", synced: "Sincronizzato",
@ -1481,6 +1486,23 @@ export default function WeeklyView() {
} }
}; };
const dissolveTab = async (tabName: string) => {
const listsToUpdate = somedayLists.filter(l => l.tab === tabName);
setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l));
if (activeSomedayTab === tabName) setSomedayTab(null);
for (const list of listsToUpdate) {
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: list.id, tab: null }),
});
} catch (e) {
console.error("Failed to dissolve tab for list:", e);
}
}
};
const filteredSomedayLists = useMemo(() => { const filteredSomedayLists = useMemo(() => {
if (activeSomedayTab === null) return somedayLists; if (activeSomedayTab === null) return somedayLists;
return somedayLists.filter(l => (l.tab || null) === activeSomedayTab); return somedayLists.filter(l => (l.tab || null) === activeSomedayTab);
@ -2613,7 +2635,8 @@ export default function WeeklyView() {
data.lists.map((l: any) => ({ data.lists.map((l: any) => ({
id: l.id, id: l.id,
title: l.title, title: l.title,
tasks: l.tasks || [], // Tasks will be overwritten/populated by fetchTasks tab: l.tab || null,
tasks: l.tasks || [],
externalId: l.externalId || null, externalId: l.externalId || null,
externalProvider: l.externalProvider || null, externalProvider: l.externalProvider || null,
})), })),
@ -6386,8 +6409,8 @@ export default function WeeklyView() {
}} }}
/> />
) : ( ) : (
<div key={tab} className="someday-tab-wrapper">
<button <button
key={tab}
className={`someday-tab-btn ${activeSomedayTab === tab ? "active" : ""}`} className={`someday-tab-btn ${activeSomedayTab === tab ? "active" : ""}`}
onClick={() => setSomedayTab(tab)} onClick={() => setSomedayTab(tab)}
onDoubleClick={() => { onDoubleClick={() => {
@ -6398,6 +6421,17 @@ export default function WeeklyView() {
> >
{tab} {tab}
</button> </button>
<button
className="someday-tab-dissolve"
onClick={(e) => {
e.stopPropagation();
dissolveTab(tab);
}}
title={t.dissolveTab}
>
<X size={8} />
</button>
</div>
) )
))} ))}
</div> </div>
@ -6473,8 +6507,8 @@ export default function WeeklyView() {
onClick={() => setSomedayTab(null)} onClick={() => setSomedayTab(null)}
>{t.allTabs}</button> >{t.allTabs}</button>
{somedayTabs.map(tab => ( {somedayTabs.map(tab => (
<div key={tab} className="someday-tab-wrapper-h">
<button <button
key={tab}
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`} className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`}
onClick={() => setSomedayTab(tab)} onClick={() => setSomedayTab(tab)}
onDoubleClick={() => { onDoubleClick={() => {
@ -6482,6 +6516,12 @@ export default function WeeklyView() {
setRenamingTabValue(tab); setRenamingTabValue(tab);
}} }}
>{tab}</button> >{tab}</button>
<button
className="someday-tab-dissolve-h"
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
title={t.dissolveTab}
><X size={10} /></button>
</div>
))} ))}
</> </>
)} )}
@ -6496,11 +6536,17 @@ export default function WeeklyView() {
onClick={() => setSomedayTab(null)} onClick={() => setSomedayTab(null)}
>{t.allTabs}</button> >{t.allTabs}</button>
{somedayTabs.map(tab => ( {somedayTabs.map(tab => (
<div key={tab} className="someday-tab-wrapper-h">
<button <button
key={tab}
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`} className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`}
onClick={() => setSomedayTab(tab)} onClick={() => setSomedayTab(tab)}
>{tab}</button> >{tab}</button>
<button
className="someday-tab-dissolve-h"
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
title={t.dissolveTab}
><X size={10} /></button>
</div>
))} ))}
</div> </div>
)} )}

View File

@ -39,6 +39,36 @@ const createClient = (serverUrl: string, username: string, password: string) =>
}); });
}; };
/**
* Login with fallback: Synology's CalDAV doesn't support well-known discovery,
* so if login() fails with "cannot find homeUrl", retry with the serverUrl as homeUrl.
*/
const loginClient = async (client: DAVClient, serverUrl: string, username: string) => {
try {
await client.login();
} catch (err: any) {
if (err?.message?.includes('homeUrl')) {
// Synology doesn't support .well-known discovery — derive homeUrl from serverUrl
let homeUrl = serverUrl.replace(/\/$/, '');
if (!homeUrl.includes('/caldav')) {
homeUrl = `${homeUrl}/caldav/${username}`;
}
// Strip any calendar-specific path segments (UUID), keep up to username
const match = homeUrl.match(/^(https?:\/\/[^/]+\/caldav(?:\.php)?\/[^/]+)\/?/);
if (match) homeUrl = match[1] + '/';
console.log('[SYNOLOGY CALENDAR] Well-known discovery failed, retrying with homeUrl:', homeUrl);
(client as any).account = {
serverUrl: client.serverUrl,
accountType: 'caldav',
homeUrl,
};
} else {
throw err;
}
}
};
/** /**
* Validate credentials by attempting to fetch calendars * Validate credentials by attempting to fetch calendars
* @returns List of found calendars if successful * @returns List of found calendars if successful
@ -46,7 +76,7 @@ const createClient = (serverUrl: string, username: string, password: string) =>
export const validateCredentials = async (serverUrl: string, username: string, password: string): Promise<SynologyCalendar[]> => { export const validateCredentials = async (serverUrl: string, username: string, password: string): Promise<SynologyCalendar[]> => {
try { try {
const client = createClient(serverUrl, username, password); const client = createClient(serverUrl, username, password);
await client.login(); await loginClient(client, serverUrl, username);
const calendars = await client.fetchCalendars(); const calendars = await client.fetchCalendars();
@ -91,7 +121,7 @@ export const getUpcomingEvents = async (
): Promise<SynologyCalendarEvent[]> => { ): Promise<SynologyCalendarEvent[]> => {
try { try {
const client = createClient(serverUrl, username, password); const client = createClient(serverUrl, username, password);
await client.login(); await loginClient(client, serverUrl, username);
const calendars = await client.fetchCalendars(); const calendars = await client.fetchCalendars();
const getPath = (url: string) => { const getPath = (url: string) => {
@ -264,7 +294,7 @@ export const createEvent = async (
): Promise<SynologyCalendarEvent> => { ): Promise<SynologyCalendarEvent> => {
try { try {
const client = createClient(serverUrl, username, password); const client = createClient(serverUrl, username, password);
await client.login(); await loginClient(client, serverUrl, username);
const calendars = await client.fetchCalendars(); const calendars = await client.fetchCalendars();
const targetCalendar = calendars.find(c => c.url === calendarUrl); const targetCalendar = calendars.find(c => c.url === calendarUrl);
@ -381,7 +411,7 @@ export const updateEvent = async (
): Promise<SynologyCalendarEvent> => { ): Promise<SynologyCalendarEvent> => {
try { try {
const client = createClient(serverUrl, username, password); const client = createClient(serverUrl, username, password);
await client.login(); await loginClient(client, serverUrl, username);
let targetObject: any = null; let targetObject: any = null;
@ -504,7 +534,7 @@ export const deleteEvent = async (
): Promise<void> => { ): Promise<void> => {
try { try {
const client = createClient(serverUrl, username, password); const client = createClient(serverUrl, username, password);
await client.login(); await loginClient(client, serverUrl, username);
const parsed = parseCaldavId(eventId); const parsed = parseCaldavId(eventId);
if (parsed) { if (parsed) {