From b241656bb463e252023e6de9e791fb91350eb9c2 Mon Sep 17 00:00:00 2001 From: Cam Clarke Date: Sun, 1 Mar 2026 02:14:20 +0000 Subject: [PATCH 1/2] Add Todoist project visibility filter setting --- src/lib/components/Settings.svelte | 191 ++++++++++++++++++++++++ src/lib/components/Tasks.svelte | 36 ++++- src/lib/components/ui/Checkbox.svelte | 4 +- src/lib/stores/settings-store.svelte.js | 1 + 4 files changed, 227 insertions(+), 5 deletions(-) diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 7dcb2c4..2f5aeb9 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -30,6 +30,10 @@ let googleTasksApi = $state(null) let signingIn = $state(false) let signInError = $state('') + let todoistProjects = $state([]) + let todoistProjectsLoading = $state(false) + let todoistProjectsError = $state('') + let todoistProjectsRequestId = 0 function googleSignInLabel() { if (settings.googleTasksSignedIn) return 'sign out' @@ -74,6 +78,119 @@ } } + async function loadTodoistProjects(token) { + const trimmedToken = token?.trim() + if (!trimmedToken) { + todoistProjectsRequestId++ + todoistProjects = [] + todoistProjectsLoading = false + todoistProjectsError = '' + return + } + + const requestId = ++todoistProjectsRequestId + todoistProjectsLoading = true + todoistProjectsError = '' + + try { + const formData = new FormData() + formData.append('sync_token', '*') + formData.append('resource_types', JSON.stringify(['projects'])) + + const response = await fetch('https://api.todoist.com/api/v1/sync', { + method: 'POST', + headers: { + Authorization: `Bearer ${trimmedToken}`, + }, + body: formData, + }) + + if (!response.ok) { + throw new Error(`todoist projects fetch failed: ${response.status}`) + } + + const data = await response.json() + + if (requestId !== todoistProjectsRequestId) return + + todoistProjects = (data.projects || []) + .filter((project) => !project.is_deleted) + .map((project) => ({ + id: project.id, + name: project.name, + childOrder: project.child_order ?? 0, + })) + .sort((a, b) => a.childOrder - b.childOrder) + .map(({ id, name }) => ({ id, name })) + + if (Array.isArray(settings.todoistVisibleProjectIds)) { + const validProjectIds = new Set( + todoistProjects.map((project) => String(project.id)) + ) + settings.todoistVisibleProjectIds = + settings.todoistVisibleProjectIds.filter((id) => + validProjectIds.has(String(id)) + ) + } + } catch (err) { + if (requestId !== todoistProjectsRequestId) return + todoistProjects = [] + todoistProjectsError = 'failed to load projects' + console.error('todoist projects sync failed:', err) + } finally { + if (requestId === todoistProjectsRequestId) { + todoistProjectsLoading = false + } + } + } + + function isTodoistProjectSelected(projectId) { + if (settings.todoistVisibleProjectIds === null) return true + const id = String(projectId) + return (settings.todoistVisibleProjectIds || []).some( + (selectedId) => String(selectedId) === id + ) + } + + function setTodoistProjectVisibility(projectId, visible) { + const id = String(projectId) + const allProjectIds = todoistProjects.map((project) => + String(project.id) + ) + const currentProjectIds = + settings.todoistVisibleProjectIds === null + ? allProjectIds + : (settings.todoistVisibleProjectIds || []).map((selectedId) => + String(selectedId) + ) + + const nextProjectIds = new Set(currentProjectIds) + if (visible) { + nextProjectIds.add(id) + } else { + nextProjectIds.delete(id) + } + + settings.todoistVisibleProjectIds = Array.from(nextProjectIds) + } + + $effect(() => { + const shouldLoadTodoistProjects = + showSettings && + settings.taskBackend === 'todoist' && + Boolean(settings.todoistApiToken?.trim()) + + if (!shouldLoadTodoistProjects) { + todoistProjectsRequestId++ + todoistProjects = [] + todoistProjectsLoading = false + todoistProjectsError = '' + return + } + + loadTodoistProjects(settings.todoistApiToken) + }) + let iconPickerOpen = $state(null) let iconPickerRef = $state(null) @@ -456,6 +573,64 @@ bind:value={settings.todoistApiToken} /> + + {#if settings.todoistApiToken?.trim()} +
+
todoist projects
+
+ + + +
+ {#if todoistProjectsLoading} +
loading projects...
+ {:else if todoistProjectsError} +
+ {todoistProjectsError} +
+ {:else if todoistProjects.length === 0} +
no projects found
+ {:else} +
+ {#each todoistProjects as project} + + setTodoistProjectVisibility( + project.id, + event.target.checked + )} + > + {project.name} + + {/each} +
+ {/if} +
+ {/if} {/if} {#if settings.taskBackend === 'google-tasks'} @@ -953,6 +1128,22 @@ gap: 1rem; margin-bottom: 1rem; } + .todoist-project-actions { + display: flex; + gap: 1rem; + margin-bottom: 0.5rem; + } + .todoist-project-list { + display: grid; + gap: 0.25rem; + max-height: 12rem; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--bg-3) var(--bg-1); + } + .helper-text { + color: var(--txt-3); + } .bracket { color: var(--txt-3); } diff --git a/src/lib/components/Tasks.svelte b/src/lib/components/Tasks.svelte index 1e321d4..156a387 100644 --- a/src/lib/components/Tasks.svelte +++ b/src/lib/components/Tasks.svelte @@ -22,7 +22,26 @@ let initialLoad = $state(true) let previousToken = $state(null) let previousBackend = $state(null) - let taskCount = $derived(tasks.filter((task) => !task.checked).length) + let filteredTasks = $derived.by(() => { + if (settings.taskBackend !== 'todoist') { + return tasks + } + + if (settings.todoistVisibleProjectIds === null) { + return tasks + } + + const visibleProjectIds = new Set( + (settings.todoistVisibleProjectIds || []).map((id) => String(id)) + ) + + return tasks.filter((task) => + visibleProjectIds.has(String(task.project_id)) + ) + }) + let taskCount = $derived( + filteredTasks.filter((task) => !task.checked).length + ) let taskLabel = $derived(taskCount === 1 ? 'task' : 'tasks') let backendUrl = $derived.by(() => { if (settings.taskBackend === 'todoist') @@ -209,6 +228,17 @@ id: p.id, name: p.name, })) + + // Remove project IDs that no longer exist. + if (Array.isArray(settings.todoistVisibleProjectIds)) { + const validProjectIds = new Set( + availableProjects.map((project) => String(project.id)) + ) + settings.todoistVisibleProjectIds = + settings.todoistVisibleProjectIds.filter((id) => + validProjectIds.has(String(id)) + ) + } } else if (settings.taskBackend === 'google-tasks') { availableProjects = (api.data?.tasklists || []).map((tl) => ({ id: tl.id, @@ -432,7 +462,7 @@ {parsedProject} disabled={addingTask} loading={addingTask} - show={tasks.length === 0} + show={filteredTasks.length === 0} onsubmit={addTask} /> @@ -440,7 +470,7 @@
- {#each tasks as task} + {#each filteredTasks as task}
+
+ {#if error} +
{error}
+ {:else if noCalendarsSelected} +
no calendars selected
+ {:else if events.length === 0} +
no upcoming events
+ {:else} +
+ {#each events as event} +
+
+ {formatEventDate(event.start, event.allDay)} +
+ {#if event.link} + + {event.title} + + {:else} +
{event.title}
+ {/if} + {#if event.location} +
{event.location}
+ {/if} + {#if !event.calendarPrimary} +
#{event.calendarName}
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/src/lib/components/Links.svelte b/src/lib/components/Links.svelte index 25f0d4b..855e148 100644 --- a/src/lib/components/Links.svelte +++ b/src/lib/components/Links.svelte @@ -1,7 +1,13 @@
links
+
+ > + + +
+ {#if launcherFeedback} +
{launcherFeedback}
+ {/if} +
{#each columns as column}
.panel { display: flex; + flex-wrap: wrap; gap: 1.5rem; } + .launcher { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + } + .launcher-prefix { + color: var(--txt-3); + } + .launcher-input { + flex: 1; + min-width: 0; + background: var(--bg-2); + border: 2px solid var(--bg-3); + padding: 0.25rem 0.5rem; + } + .launcher-input:focus { + border-color: var(--txt-3); + } + .launcher-input::placeholder { + color: var(--txt-4); + } + .launcher-open { + color: var(--txt-2); + } + .launcher-feedback { + width: 100%; + color: var(--txt-3); + margin-top: -1rem; + margin-bottom: -0.25rem; + } .link:hover .prefix, .link:hover .icon { color: var(--txt-2); diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 2f5aeb9..c679bf2 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -5,6 +5,7 @@ saveSettings, settings, resetSettings, + normalizeMainWidgetOrder, } from '../stores/settings-store.svelte.js' import { themeNames, @@ -14,6 +15,7 @@ import RadioButton from './ui/RadioButton.svelte' import Checkbox from './ui/Checkbox.svelte' import { createTaskBackend } from '../backends/index.js' + import GoogleCalendarAPI from '../api/google-calendar-api.js' import { isChrome } from '../utils/browser-detect.js' import { guessIconSlug, isValidSlug, extractDomain } from '../utils/link-icons.js' import IconPicker from './IconPicker.svelte' @@ -23,6 +25,7 @@ // Check if Google Tasks is available (Chrome only) const googleTasksAvailable = isChrome() + const googleCalendarAvailable = isChrome() // @ts-ignore const version = __APP_VERSION__ @@ -30,10 +33,24 @@ let googleTasksApi = $state(null) let signingIn = $state(false) let signInError = $state('') + let googleCalendarApi = $state(null) + let calendarSigningIn = $state(false) + let calendarSignInError = $state('') + let googleCalendars = $state([]) + let googleCalendarsLoading = $state(false) + let googleCalendarsError = $state('') + let googleCalendarsRequestId = 0 let todoistProjects = $state([]) let todoistProjectsLoading = $state(false) let todoistProjectsError = $state('') let todoistProjectsRequestId = 0 + const widgetLabels = { + weather: 'weather', + tasks: 'tasks', + calendar: 'calendar', + } + + let widgetReorderMode = $state(false) function googleSignInLabel() { if (settings.googleTasksSignedIn) return 'sign out' @@ -78,6 +95,118 @@ } } + function googleCalendarSignInLabel() { + if (settings.googleCalendarSignedIn) return 'sign out' + if (calendarSignInError) return calendarSignInError + if (calendarSigningIn) return 'signing in...' + return 'sign in with google' + } + + function ensureGoogleCalendarApi() { + if (!googleCalendarApi) { + googleCalendarApi = new GoogleCalendarAPI() + } + return googleCalendarApi + } + + async function handleGoogleCalendarSignIn() { + try { + calendarSigningIn = true + calendarSignInError = '' + await ensureGoogleCalendarApi().signIn() + settings.googleCalendarSignedIn = true + saveSettings(settings) + } catch (err) { + console.error('google calendar sign in failed:', err) + calendarSignInError = 'sign in failed' + settings.googleCalendarSignedIn = false + } finally { + calendarSigningIn = false + } + } + + async function handleGoogleCalendarSignOut() { + try { + await ensureGoogleCalendarApi().signOut() + settings.googleCalendarSignedIn = false + saveSettings(settings) + calendarSignInError = '' + } catch (err) { + console.error('google calendar sign out failed:', err) + } + } + + async function loadGoogleCalendars() { + const requestId = ++googleCalendarsRequestId + + if (!settings.googleCalendarSignedIn) { + googleCalendars = [] + googleCalendarsLoading = false + googleCalendarsError = '' + return + } + + googleCalendarsLoading = true + googleCalendarsError = '' + + try { + const calendars = await ensureGoogleCalendarApi().getCalendars() + if (requestId !== googleCalendarsRequestId) return + + googleCalendars = calendars + + if (Array.isArray(settings.googleCalendarVisibleCalendarIds)) { + const validCalendarIds = new Set( + googleCalendars.map((calendar) => String(calendar.id)) + ) + settings.googleCalendarVisibleCalendarIds = + settings.googleCalendarVisibleCalendarIds.filter( + (calendarId) => + validCalendarIds.has(String(calendarId)) + ) + } + } catch (err) { + if (requestId !== googleCalendarsRequestId) return + googleCalendars = [] + googleCalendarsError = 'failed to load calendars' + console.error('google calendar list load failed:', err) + } finally { + if (requestId === googleCalendarsRequestId) { + googleCalendarsLoading = false + } + } + } + + function isGoogleCalendarSelected(calendarId) { + if (settings.googleCalendarVisibleCalendarIds === null) return true + const id = String(calendarId) + return (settings.googleCalendarVisibleCalendarIds || []).some( + (selectedId) => String(selectedId) === id + ) + } + + function setGoogleCalendarVisibility(calendarId, visible) { + const id = String(calendarId) + const allCalendarIds = googleCalendars.map((calendar) => + String(calendar.id) + ) + const currentCalendarIds = + settings.googleCalendarVisibleCalendarIds === null + ? allCalendarIds + : (settings.googleCalendarVisibleCalendarIds || []).map( + (selectedId) => String(selectedId) + ) + + const nextCalendarIds = new Set(currentCalendarIds) + if (visible) { + nextCalendarIds.add(id) + } else { + nextCalendarIds.delete(id) + } + + settings.googleCalendarVisibleCalendarIds = Array.from(nextCalendarIds) + } + async function loadTodoistProjects(token) { const trimmedToken = token?.trim() if (!trimmedToken) { @@ -191,6 +320,27 @@ loadTodoistProjects(settings.todoistApiToken) }) + $effect(() => { + const shouldLoadGoogleCalendars = + showSettings && + googleCalendarAvailable && + settings.googleCalendarSignedIn + + if (!shouldLoadGoogleCalendars) { + googleCalendarsRequestId++ + googleCalendars = [] + googleCalendarsLoading = false + googleCalendarsError = '' + return + } + + loadGoogleCalendars() + }) + + $effect(() => { + ensureMainWidgetOrder() + }) + let iconPickerOpen = $state(null) let iconPickerRef = $state(null) @@ -228,6 +378,9 @@ function handleClose() { saveSettings(settings) + widgetReorderMode = false + draggedWidgetIndex = null + widgetDropSlotIndex = null closeSettings() } @@ -299,10 +452,104 @@ { key: 'txtErr', label: 'error' }, ] + function arraysEqual(a, b) { + if (a.length !== b.length) return false + return a.every((value, index) => value === b[index]) + } + + function getWidgetOrderForEditor(order) { + const normalized = normalizeMainWidgetOrder(order) + if (googleCalendarAvailable) return normalized + return normalized.filter((widgetId) => widgetId !== 'calendar') + } + + function ensureMainWidgetOrder() { + const normalized = normalizeMainWidgetOrder(settings.mainWidgetOrder) + if (!arraysEqual(normalized, settings.mainWidgetOrder || [])) { + settings.mainWidgetOrder = normalized + } + } + + function isWidgetShown(widgetId) { + switch (widgetId) { + case 'weather': + return settings.showWeather + case 'tasks': + return settings.showTasks + case 'calendar': + return googleCalendarAvailable && settings.showCalendar + default: + return false + } + } + + function toggleWidgetReorderMode() { + widgetReorderMode = !widgetReorderMode + if (widgetReorderMode) { + ensureMainWidgetOrder() + } else { + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + } + + function reorderMainWidgets(slotIndex) { + ensureMainWidgetOrder() + + const visibleOrder = getWidgetOrderForEditor(settings.mainWidgetOrder) + if (draggedWidgetIndex === null || draggedWidgetIndex >= visibleOrder.length) { + return + } + + if ( + slotIndex === draggedWidgetIndex || + slotIndex === draggedWidgetIndex + 1 + ) { + return + } + + const nextVisibleOrder = [...visibleOrder] + const draggedItem = nextVisibleOrder[draggedWidgetIndex] + nextVisibleOrder.splice(draggedWidgetIndex, 1) + const adjustedSlotIndex = + draggedWidgetIndex < slotIndex ? slotIndex - 1 : slotIndex + nextVisibleOrder.splice(adjustedSlotIndex, 0, draggedItem) + + if (googleCalendarAvailable) { + settings.mainWidgetOrder = nextVisibleOrder + return + } + + const normalizedOrder = normalizeMainWidgetOrder(settings.mainWidgetOrder) + const calendarIndex = normalizedOrder.indexOf('calendar') + const orderWithoutCalendar = normalizedOrder.filter( + (widgetId) => widgetId !== 'calendar' + ) + + if (!arraysEqual(orderWithoutCalendar, nextVisibleOrder)) { + let insertAt = nextVisibleOrder.length + if (calendarIndex >= 0 && calendarIndex < normalizedOrder.length - 1) { + const nextWidgetAfterCalendar = normalizedOrder[calendarIndex + 1] + const nextIndex = nextVisibleOrder.indexOf(nextWidgetAfterCalendar) + if (nextIndex >= 0) { + insertAt = nextIndex + } + } + + nextVisibleOrder.splice(insertAt, 0, 'calendar') + } + + settings.mainWidgetOrder = normalizeMainWidgetOrder(nextVisibleOrder) + } + // Drag and drop state let draggedIndex = $state(null) let dropSlotIndex = $state(null) // Which slot (between items) to drop into + // Main widget reorder state + let draggedWidgetIndex = $state(null) + let widgetDropSlotIndex = $state(null) + function handleDragStart(event, index) { draggedIndex = index event.dataTransfer.effectAllowed = 'move' @@ -357,6 +604,34 @@ dropSlotIndex = null } + function handleWidgetDragStart(event, index) { + draggedWidgetIndex = index + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', String(index)) + } + + function handleWidgetDropZoneDragOver(event, slotIndex) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + widgetDropSlotIndex = slotIndex + } + + function handleWidgetDropZoneDragLeave() { + widgetDropSlotIndex = null + } + + function handleWidgetDropZoneDrop(event, slotIndex) { + event.preventDefault() + reorderMainWidgets(slotIndex) + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + + function handleWidgetDragEnd() { + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + let locationLoading = $state(false) let locationError = $state(null) let locationErrorTimeout = null @@ -439,7 +714,12 @@
-
widgets
+
+
widgets
+ +
clock stats @@ -447,8 +727,69 @@ >weather tasks + {#if googleCalendarAvailable} + calendar + {/if} links
+ {#if widgetReorderMode} + {@const widgetEditorOrder = getWidgetOrderForEditor(settings.mainWidgetOrder)} +
+ drag to reorder the main widget row +
+
+ {#each widgetEditorOrder as widgetId, index} +
handleWidgetDropZoneDragOver(e, index)} + ondragleave={handleWidgetDropZoneDragLeave} + ondrop={(e) => handleWidgetDropZoneDrop(e, index)} + role="none" + >
+
+ handleWidgetDragStart(e, index)} + ondragend={handleWidgetDragEnd} + role="button" + tabindex="0">= + {widgetLabels[widgetId]} + + {isWidgetShown(widgetId) ? 'shown' : 'hidden'} + +
+ {/each} +
+ handleWidgetDropZoneDragOver( + e, + widgetEditorOrder.length + )} + ondragleave={handleWidgetDropZoneDragLeave} + ondrop={(e) => + handleWidgetDropZoneDrop( + e, + widgetEditorOrder.length + )} + role="none" + >
+
+ {/if}
theme
@@ -648,6 +989,90 @@
{/if} + {#if googleCalendarAvailable} +
+
google calendar authentication
+ +
+
+ + +
+ + {#if settings.googleCalendarSignedIn} +
+
google calendars
+
+ + + +
+ {#if googleCalendarsLoading} +
loading calendars...
+ {:else if googleCalendarsError} +
{googleCalendarsError}
+ {:else if googleCalendars.length === 0} +
no calendars found
+ {:else} +
+ {#each googleCalendars as calendar} + + setGoogleCalendarVisibility( + calendar.id, + event.target.checked + )} + > + {calendar.name} + {calendar.primary ? ' (primary)' : ''} + + {/each} +
+ {/if} +
+ {/if} + {/if} +
weather forecast
@@ -1014,12 +1439,24 @@ flex: 1; margin-bottom: 1.5rem; } + .top-gap { + margin-top: 0.5rem; + } .group > label, .col > label, .setting-label { display: block; margin-bottom: 0.5rem; } + .widgets-header { + display: flex; + justify-content: space-between; + align-items: center; + + .setting-label { + margin: 0; + } + } .group input[type='text'], .group input[type='password'], .group input[type='number'], @@ -1074,6 +1511,35 @@ height: 2px; background-color: var(--txt-2); } + .widget-order-list { + display: flex; + flex-direction: column; + margin-top: 0.5rem; + border: 2px solid var(--bg-3); + background: var(--bg-2); + padding: 0.25rem 0.5rem; + } + .widget-order-item { + display: flex; + align-items: center; + min-height: 2rem; + gap: 0.25rem; + } + .widget-order-item.dragging { + opacity: 0.5; + } + .widget-order-name { + color: var(--txt-2); + flex: 1; + } + .widget-order-state { + color: var(--txt-3); + font-size: 0.75rem; + text-transform: uppercase; + } + .widget-order-state.active { + color: var(--txt-2); + } .link { display: flex; align-items: center; diff --git a/src/lib/components/Weather.svelte b/src/lib/components/Weather.svelte index 9369530..3dc1f50 100644 --- a/src/lib/components/Weather.svelte +++ b/src/lib/components/Weather.svelte @@ -1,6 +1,7 @@ + + + + diff --git a/src/lib/stores/settings-store.svelte.js b/src/lib/stores/settings-store.svelte.js index 9b0f64a..9807ffa 100644 --- a/src/lib/stores/settings-store.svelte.js +++ b/src/lib/stores/settings-store.svelte.js @@ -1,5 +1,30 @@ import { defaultCustomColors } from '../config/themes.js' +export const MAIN_WIDGET_IDS = ['weather', 'tasks', 'calendar'] + +export function normalizeMainWidgetOrder(order) { + if (!Array.isArray(order)) { + return [...MAIN_WIDGET_IDS] + } + + const normalized = [] + const seen = new Set() + + for (const id of order) { + if (!MAIN_WIDGET_IDS.includes(id) || seen.has(id)) continue + seen.add(id) + normalized.push(id) + } + + for (const id of MAIN_WIDGET_IDS) { + if (!seen.has(id)) { + normalized.push(id) + } + } + + return normalized +} + function detectFormatPreferences() { try { const use24h = !new Intl.DateTimeFormat(undefined, { @@ -29,6 +54,9 @@ let defaultSettings = { todoistApiToken: '', todoistVisibleProjectIds: null, googleTasksSignedIn: false, + googleCalendarSignedIn: false, + googleCalendarMaxEvents: 5, + googleCalendarVisibleCalendarIds: null, locationMode: 'manual', latitude: null, longitude: null, @@ -85,6 +113,8 @@ let defaultSettings = { showStats: true, showWeather: true, showTasks: true, + showCalendar: false, + mainWidgetOrder: [...MAIN_WIDGET_IDS], showLinks: true, } @@ -103,6 +133,22 @@ function loadSettings() { parsed.showLinkIcons === false ? 'arrow' : 'icons' delete merged.showLinkIcons } + // remove deprecated standalone radar widget setting + delete merged.showWeatherRadar + // remove deprecated pomodoro settings + delete merged.showPomodoro + delete merged.pomodoroFocusMinutes + delete merged.pomodoroShortBreakMinutes + delete merged.pomodoroLongBreakMinutes + delete merged.pomodoroLongBreakEvery + delete merged.pomodoroAutoStartBreaks + delete merged.pomodoroAutoStartFocus + delete merged.pomodoroSoundEnabled + delete merged.pomodoroSoundType + delete merged.pomodoroSoundVolume + merged.mainWidgetOrder = normalizeMainWidgetOrder( + merged.mainWidgetOrder + ) return merged } } catch (error) {