diff --git a/jgclark.Dashboard/CHANGELOG.md b/jgclark.Dashboard/CHANGELOG.md index 924560b7b..38a92dc0b 100644 --- a/jgclark.Dashboard/CHANGELOG.md +++ b/jgclark.Dashboard/CHANGELOG.md @@ -8,6 +8,14 @@ For more details see the [plugin's documentation](https://github.com/NotePlan/pl - TODO: fix long-standing layout bug where some tooltips were getting clipped - TODO: fix isNoteFromAllowedFolder() for teamspace or possibly 2025-W21.md --> +## [2.4.0.b58] 2026-07-30 +- Fix: reminders whose own section was switched off were discarded silently. Overdue, yesterday and **untimed today** reminders now fall back to the **Reminders** section (own section → Overdue → Reminders), so nothing disappears just because a section is hidden. Both fallbacks stay subject to **Show Current Reminders** / **Show Undated/Overdue Reminders**, so an item you deliberately hid is not resurrected. +- Fix: the **Overdue** section counted reminders it did not display. When `maxItemsToShowInSection` left fewer slots than there were reminders, the surplus was sliced off the list but still added to the total, so the header claimed more items than the section held. Now counts only what was added, and warns when some do not fit. +- Fix: **Spaces to Include** holding only unreachable teamspace IDs hid every task. Private notes are read only when `private` is in that list, so a list of stale IDs (e.g. after signing out of Spaces) filtered out every note before any task could be read - reminders still appeared, which made it look like tasks had vanished. Unreachable IDs are now discarded, falling back to private notes with a warning. Deliberate configurations are untouched. +- Doc: a timed reminder due later today is intentionally shown nowhere until its time arrives. This was undocumented and indistinguishable from the bug above; there is now a DESIGN DECISION note at the filter in `dataGenerationDays.js` naming what to change to show them early, and the pitfall of them then appearing twice. +- New: log when reminders or notes are dropped - reminder buckets with no visible host section, reminder lists disabled in NotePlan (a reminder there is invisible with no other clue), and overdue reminders that did not fit the section limit. +- dev: temporary diagnostic logging retained for ongoing testing - one line per reminder (list, title, raw EventKit date vs derived date/time), one per bucket assignment, and the REM section size. + ## [2.4.0.b57] 2026-07-29 - New: sticky **Filter settings** search at the top of Dashboard Settings - matches label/description (from 3+ characters). - Reduce opacity of 'chips' showing Reminder list name, and note names. diff --git a/jgclark.Dashboard/plugin.json b/jgclark.Dashboard/plugin.json index 6abdc50e2..d31cfd69f 100644 --- a/jgclark.Dashboard/plugin.json +++ b/jgclark.Dashboard/plugin.json @@ -8,7 +8,7 @@ "plugin.description": "A Dashboard for NotePlan, that in one place shows:\n- a compact list of open tasks and checklists from today's note\n- scheduled open tasks and checklists from other notes.\n- similarly for yesterday's note, tomorrow's note, and the weekly, monthly and quarterly notes too (if used)\n- all overdue tasks\n- all open tasks and checklists that contain particular @tags or #mentions of your choosing\n- the next notes ready to review (if you use the 'Projects and Reviews' plugin).\nIt includes many other ways of speeding up managing your tasks: see the website for more details.", "plugin.author": "@jgclark", "plugin.comment": "TODO: On full release, change minAppVersion down to 3.7?", - "plugin.version": "2.4.0.b57", + "plugin.version": "2.4.0.b58", "plugin.releaseStatus": "beta", "plugin.hidden": false, "plugin.lastUpdateInfo": "2.4.0: new 'Active Projects' Section. New 'Spaces to Include' setting which controls which (Team)Spaces you wish to include, plus whether or not to include the Private 'Space' (all notes not in a Space)\n2.3.3: new 'Year' section available.\n2.3.2: fix display when there are no priority items shown.\n2.3.1: fix for possible loss of settings error when upgrading.\n2.3.0: Support for NotePlan (Team)Spaces. Can re-order display of Sections.New '/backupSettings' command. Added 'noteTags' feature. Speeded up Tag/Mention sections. Layout improvements. Lots of other small fixes and improvements.\n2.2.1: Add new sorting option for Tag and Overdue sections.\n2.2.0: Add 'Search' section. New keyboard shortcuts. Plus many small improvements, bug fixes and performance improvements. See documentation for details.\n2.1.10: More move-under-heading options. Bug fixes and performance improvements.\n2.1.9: performance improvements and better UI for iPhone users.\n2.1.8: various fixes and small improvements.\n2.1.7: various fixes and small improvements.\n2.1.6: allow all current timeblocks to be shown, not just the first. Add new @repeat()s if using the extended syntax from the Repeat Extensions plugin. Bug fixes.\n2.1.5: fixes to time blocks and scheduling items.\n2.1.4: fix to Interactive Processing, and Edit All Perspectives dialog now shows unsaved changes.", diff --git a/jgclark.Dashboard/src/dashboardHelpers.js b/jgclark.Dashboard/src/dashboardHelpers.js index 301a5136e..b3260019f 100644 --- a/jgclark.Dashboard/src/dashboardHelpers.js +++ b/jgclark.Dashboard/src/dashboardHelpers.js @@ -707,7 +707,7 @@ export function getOpenItemParasForTimePeriod( const { matchingNotes, possTimePeriodNote } = getMatchingCalendarNotes(NPCalendarFilenameStr) // Filter notes by allowed teamspaces - const allowedTeamspaceIDs = dashboardSettings.includedTeamspaces ?? ['private'] + const allowedTeamspaceIDs = resolveAllowedTeamspaceIDs(dashboardSettings) const filteredMatchingNotes = matchingNotes.filter((note) => isNoteFromAllowedTeamspace(note, allowedTeamspaceIDs)) logDebug('getOpenItemParasForTimePeriod', `- after teamspace filter: ${filteredMatchingNotes.length} of ${matchingNotes.length} notes`) @@ -1119,6 +1119,47 @@ export function isLineDisallowedByIgnoreTerms(lineContent: string, ignoreItemsWi return matchFound } +/** + * Resolve which teamspaces this config should read notes from, discarding IDs that + * no longer exist. + * + * Why this exists: `includedTeamspaces` is a plain list of IDs, and 'private' has to + * appear in it for your own notes to be read at all. So a list holding only teamspace + * IDs silently hides every private note -- which for most users is every note they + * have. That is survivable while the IDs are real, because it is what you asked for. + * It is not survivable when the IDs are stale: signing out of Spaces (or leaving them) + * leaves a list that matches nothing, the Dashboard shows no tasks at all, and the + * settings UI reports "You are not a member of any Spaces" so there is nothing to + * click to undo it. Seen in the wild: 6 unreachable IDs, no 'private', zero tasks. + * + * When nothing in the list is reachable, the only sensible reading is private notes. + * @param {TDashboardSettings} dashboardSettings + * @returns {Array} teamspace IDs to allow, possibly healed to ['private'] + */ +export function resolveAllowedTeamspaceIDs(dashboardSettings: TDashboardSettings): Array { + const configured = dashboardSettings.includedTeamspaces + // Absent means "private only"; an explicitly empty list means "don't filter". + // Both are long-standing behaviour, so leave them alone. + if (!configured) return ['private'] + if (configured.length === 0) return configured + + let existingIDs: Array = [] + try { + existingIDs = getAllTeamspaceIDsAndTitles().map((t) => t.id) + } catch (err) { + // No teamspace API / not signed in: treat every configured ID as unreachable + existingIDs = [] + } + const reachable = configured.filter((id) => id === 'private' || existingIDs.includes(id)) + if (reachable.length > 0) return reachable + + logWarn( + 'resolveAllowedTeamspaceIDs', + `includedTeamspaces lists ${String(configured.length)} teamspace(s) but none are reachable and 'private' is not among them, so no note could ever match. Falling back to private notes. Check the "Spaces to Include" setting; you may be signed out of Spaces.`, + ) + return ['private'] +} + /** * Check if a note is from an allowed teamspace based on dashboard settings. * If no teamspaces specified, allow all (backward compatibility). @@ -1151,7 +1192,7 @@ export function filterNotesByAllowedTeamspaces( notes: Array, dashboardSettings: TDashboardSettings ): Array { - const allowedTeamspaceIDs = dashboardSettings.includedTeamspaces ?? ['private'] + const allowedTeamspaceIDs = resolveAllowedTeamspaceIDs(dashboardSettings) return notes.filter((note) => isNoteFromAllowedTeamspace(note, allowedTeamspaceIDs)) } @@ -1191,7 +1232,7 @@ export function filterParasByAllowedTeamspaces( startTime: Date, functionName: string ): Array { - const allowedTeamspaceIDs = dashboardSettings.includedTeamspaces ?? ['private'] + const allowedTeamspaceIDs = resolveAllowedTeamspaceIDs(dashboardSettings) const filteredParas = paras.filter((p) => { const note = getNoteFromPara(p) if (!note) { diff --git a/jgclark.Dashboard/src/dataGeneration.js b/jgclark.Dashboard/src/dataGeneration.js index 82fc71bee..b9d59f723 100644 --- a/jgclark.Dashboard/src/dataGeneration.js +++ b/jgclark.Dashboard/src/dataGeneration.js @@ -25,7 +25,7 @@ import { getTaggedSectionData } from './dataGenerationTags' import { getLastWeekSectionData, getThisWeekSectionData } from './dataGenerationWeeks' import { getTagSectionDetails } from './react/components/Section/sectionHelpers' import { getNestedValue, setNestedValue } from '@helpers/dataManipulation' -import { logDebug, logError } from '@helpers/dev' +import { logDebug, logError, logWarn } from '@helpers/dev' import { getLiveWindowRect, getStoredWindowRect, rectToString } from '@helpers/NPWindows' //----------------------------------------------------------------- @@ -119,6 +119,27 @@ export async function getSomeSectionsData( const overdueReminderItems = undatedOverdueRemindersEnabled ? remindersData.overdueItems.concat(yesterdaySpillToOverdue) : [] + // A reminder only reaches the UI if some section hosts it. Yesterday and overdue + // reminders now fall back to the REM ("Undated/Overdue Reminders") section when + // their own section is off, so they are only truly lost if REM is off too. + // Tomorrow has no fallback by design, so it is lost whenever Tomorrow is off. + const remCanHost = undatedOverdueRemindersEnabled + const yesterdayHomeless = + remindersData.yesterdayItems.length > 0 && yesterdayForDaySection.length === 0 && !Boolean(config.showOverdueSection) && !remCanHost + const overdueHomeless = remindersData.overdueItems.length > 0 && !Boolean(config.showOverdueSection) && !remCanHost + const tomorrowHomeless = remindersData.tomorrowItems.length > 0 && !config.showTomorrowSection + // Untimed today falls back to REM, so it is only lost when REM cannot host either. + // Timed today reminders that are not yet due are intentionally shown nowhere + // (see the DESIGN DECISION note in dataGenerationDays.js), so they are not warned about. + const untimedTodayHomeless = remindersData.untimedTodayItems.length > 0 && !config.showTodaySection && !remCanHost + if (yesterdayHomeless || overdueHomeless || tomorrowHomeless || untimedTodayHomeless) { + const parts = [] + if (yesterdayHomeless) parts.push(`${String(remindersData.yesterdayItems.length)} yesterday`) + if (overdueHomeless) parts.push(`${String(remindersData.overdueItems.length)} overdue`) + if (tomorrowHomeless) parts.push(`${String(remindersData.tomorrowItems.length)} tomorrow (no fallback: Tomorrow section off)`) + if (untimedTodayHomeless) parts.push(`${String(remindersData.untimedTodayItems.length)} untimed today`) + logWarn('getSomeSectionsData', `- ${parts.join('; ')} reminder(s) have no visible section and will not be shown anywhere`) + } // DT and TB sections are now generated separately but share paragraph data fetching if (sectionCodesToGet.includes('DT')) { diff --git a/jgclark.Dashboard/src/dataGenerationDays.js b/jgclark.Dashboard/src/dataGenerationDays.js index ae2fd687f..48af8f12d 100644 --- a/jgclark.Dashboard/src/dataGenerationDays.js +++ b/jgclark.Dashboard/src/dataGenerationDays.js @@ -321,11 +321,21 @@ export function getTimeBlockSectionData( let dueNowReminderCount = 0 // Append today's timed reminders whose due time has been reached (when Reminders section is enabled) + // + // DESIGN DECISION (@jgclark): a timed reminder due later today is shown NOWHERE + // until its time arrives. It is excluded here, and the Today section only ever + // receives *untimed* today reminders, so there is no other section that could + // pick it up -- this is deliberate, not an oversight, and it is why the REM + // fallback in dataGenerationReminders.js skips this bucket while catching the + // others. If you want "due later today" visible ahead of time, the change is to + // route the skipped items below into the Today section (or REM when Today is off) + // rather than dropping them, and to drop them from here once their time passes so + // they do not appear twice. if (remindersSectionEnabled && timedTodayReminderItems.length > 0) { const dueNowReminders = filterRemindersWhoseTimeHasBeenReached(timedTodayReminderItems) const skippedFutureCount = timedTodayReminderItems.length - dueNowReminders.length if (skippedFutureCount > 0) { - logDebug('getTimeBlockSectionData', `- skipped ${String(skippedFutureCount)} timed reminder(s) whose time has not been reached yet`) + logDebug('getTimeBlockSectionData', `- skipped ${String(skippedFutureCount)} timed reminder(s) whose time has not been reached yet (by design: not shown anywhere until due)`) } if (dueNowReminders.length > 0) { dueNowReminderCount = dueNowReminders.length diff --git a/jgclark.Dashboard/src/dataGenerationOverdue.js b/jgclark.Dashboard/src/dataGenerationOverdue.js index 723348abd..706e5bbb0 100644 --- a/jgclark.Dashboard/src/dataGenerationOverdue.js +++ b/jgclark.Dashboard/src/dataGenerationOverdue.js @@ -146,7 +146,15 @@ export async function getOverdueSectionData( const assigned = assignReminderItemsToSection(remindersToAdd, thisSectionCode, thisSectionCode, itemCount) items.push(...assigned) itemCount += assigned.length - totalOverdue += overdueReminderItems.length + // Count only what was actually added. Adding the full incoming length + // inflated the header count whenever maxItemsToShowInSection left fewer + // slots than there were reminders, so the section claimed more items + // than it showed and the extras were silently unreachable. + totalOverdue += assigned.length + const droppedForSlots = overdueReminderItems.length - remindersToAdd.length + if (droppedForSlots > 0) { + logWarn('getOverdueSectionData', `- ${String(droppedForSlots)} overdue reminder(s) did not fit in maxItemsToShowInSection=${String(maxInSection ?? 24)} and are not shown anywhere`) + } logDebug('getOverdueSectionData', `- added ${String(assigned.length)} of ${String(overdueReminderItems.length)} overdue reminder(s)`) } } diff --git a/jgclark.Dashboard/src/dataGenerationReminders.js b/jgclark.Dashboard/src/dataGenerationReminders.js index e42f92e1a..a53119a04 100644 --- a/jgclark.Dashboard/src/dataGenerationReminders.js +++ b/jgclark.Dashboard/src/dataGenerationReminders.js @@ -122,6 +122,18 @@ export function getEnabledReminderLists(): TReminderListsResult { const enabledLists = Calendar.availableReminderLists({ enabledOnly: true }) const result = titlesAndColorsFromReminderListObjects(enabledLists) logDebug('getEnabledReminderLists', `- ${String(result.titles.length)} enabled reminder list(s): ${result.titles.join(', ') || '(none)'}`) + // Name the lists that exist but are switched off in NotePlan. A reminder living + // in one of them is simply absent from the Dashboard, with nothing anywhere to + // say why -- which reads as "the Dashboard lost my reminder". + try { + const allTitles = titlesAndColorsFromReminderListObjects(Calendar.availableReminderLists()).titles + const ignored = allTitles.filter((t) => !result.titles.includes(t)) + if (ignored.length > 0) { + logDebug('getEnabledReminderLists', `- ignoring ${String(ignored.length)} list(s) disabled in NotePlan: ${ignored.join(', ')}`) + } + } catch (err) { + logDebug('getEnabledReminderLists', `- could not enumerate all lists to report ignored ones: ${err.message}`) + } return result } // Older NotePlan: cannot read enabled/disabled or colors; use all accessible list titles @@ -287,6 +299,10 @@ export function mapCalendarItemToReminderForDashboard( clof(calendarItem, "CalendarItem (for Reminder): ", ['title', 'date', 'occurences', 'isAllDay', 'isCompleted', 'priority']) clo(reminder, " => Reminder: ") } + // One compact line per reminder: the raw EventKit fields next to what we derived. + // Strictly better than the older `if (title.match(/test/i))` dump, which only + // covered reminders that happened to have "test" in the name. + logDebug('reminderFromCalendarItem', `- list="${String(listname ?? "?")}" "${String(calendarItem.title).slice(0, 34)}" rawDate=${String(calendarItem.date)} isAllDay=${String(calendarItem.isAllDay)} occurences=${String(calendarItem.occurences ? calendarItem.occurences.length : 'none')} -> date=${String(reminder.date ?? 'UNDATED')} time=${String(reminder.time ?? '-')}`) return reminder } @@ -346,6 +362,9 @@ export function assignReminderItemsToSection( idPrefix: string, startIndex: number = 0, ): Array { + if (reminderItems.length > 0) { + logDebug('assignReminderItemsToSection', `- placing ${String(reminderItems.length)} reminder(s) into section ${String(sectionCode)}`) + } return reminderItems.map((item, i) => ({ ...item, ID: `${idPrefix}-${startIndex + i}`, @@ -465,6 +484,14 @@ export function bucketReminderItems(allItems: Array): { if (skippedFutureCount > 0) { logDebug('bucketReminderItems', `- skipped ${String(skippedFutureCount)} reminder(s) dated after ${tomorrowISO}`) } + // Which bucket each reminder landed in, so a mis-bucketed one is visible. + const bucketOf = (arr, name) => arr.forEach((it) => logDebug('bucketReminderItems', `- bucket=${name} "${String(it.reminder?.title ?? '?').slice(0, 34)}" date=${String(it.reminder?.date ?? 'UNDATED')} time=${String(it.reminder?.time ?? '-')}`)) + bucketOf(timedTodayItems, 'timedToday') + bucketOf(untimedTodayItems, 'untimedToday') + bucketOf(yesterdayItems, 'yesterday') + bucketOf(tomorrowItems, 'tomorrow') + bucketOf(overdueItems, 'overdue') + bucketOf(undatedItems, 'undated') return { timedTodayItems: sortReminderSectionItems(timedTodayItems), @@ -546,8 +573,46 @@ export async function getRemindersGeneratedData( const overdueItems = undatedOverdueRemindersEnabled ? buckets.overdueItems : [] let undatedItems = undatedOverdueRemindersEnabled ? buckets.undatedItems : [] + // Fallback chain, so a dated reminder is never silently discarded just because + // the section that would have hosted it is switched off. This section's setting + // is "Show Undated/Overdue Reminders", so an overdue reminder belongs here by + // name; a yesterday one is overdue in every sense that matters once there is no + // Yesterday section to put it in; and an untimed reminder due today would + // otherwise vanish entirely when the Today section is off. Order in each case is + // own section -> Overdue (where applicable) -> here. + // + // Two buckets deliberately have no fallback: + // - tomorrow: a future reminder is neither undated nor overdue, and hiding the + // Tomorrow section is a reasonable way to say you don't want to see it yet. + // - today's TIMED reminders whose time has not been reached: see the note in + // getTimeBlockSectionData -- those are meant to stay hidden until they are due. + if (undatedOverdueRemindersEnabled) { + const overdueSectionVisible = Boolean(config.showOverdueSection) + const fallbackItems: Array = [] + if (!overdueSectionVisible && buckets.overdueItems.length > 0) { + fallbackItems.push(...buckets.overdueItems) + } + // Yesterday is a *current* reminder, so it stays subject to "Show Current + // Reminders" -- adopting it here when that toggle is off would resurrect an + // item the user explicitly hid ("reminders due today, yesterday, or tomorrow"). + if (currentRemindersEnabled && !config.showYesterdaySection && !overdueSectionVisible && buckets.yesterdayItems.length > 0) { + fallbackItems.push(...buckets.yesterdayItems) + } + // Untimed reminders due today have only ever had one home, the Today section, + // so with that off they had nowhere to go at all. Same "Show Current Reminders" + // gate as yesterday, for the same reason. + if (currentRemindersEnabled && !config.showTodaySection && buckets.untimedTodayItems.length > 0) { + fallbackItems.push(...buckets.untimedTodayItems) + } + if (fallbackItems.length > 0) { + logDebug('getRemindersGeneratedData', `- REM fallback: adopting ${String(fallbackItems.length)} reminder(s) whose own section is off (overdueVisible=${String(overdueSectionVisible)} yesterdayVisible=${String(Boolean(config.showYesterdaySection))})`) + undatedItems = sortReminderSectionItems(undatedItems.concat(fallbackItems)) + } + } + const maxInSection = config.maxItemsToShowInSection ?? 24 const totalUndatedCount = undatedItems.length + logDebug('getRemindersGeneratedData', `- REM section will hold ${String(undatedItems.length)} reminder(s)`) if (totalUndatedCount > maxInSection) { undatedItems = undatedItems.slice(0, maxInSection) } diff --git a/jgclark.Dashboard/src/dataGenerationTags.js b/jgclark.Dashboard/src/dataGenerationTags.js index 4669fea35..dd12a074f 100644 --- a/jgclark.Dashboard/src/dataGenerationTags.js +++ b/jgclark.Dashboard/src/dataGenerationTags.js @@ -6,7 +6,7 @@ import moment from 'moment/min/moment-with-locales' import type { TDashboardSettings, TSection, TSectionItem, TSectionDetails } from './types' -import { createSectionItemObject, isLineDisallowedByIgnoreTerms, isNoteFromAllowedTeamspace, makeDashboardParas } from './dashboardHelpers' +import { createSectionItemObject, isLineDisallowedByIgnoreTerms, isNoteFromAllowedTeamspace, makeDashboardParas, resolveAllowedTeamspaceIDs } from './dashboardHelpers' import { tagParasFromNote } from './demoData' import { isTagCacheEnabled } from './dashboardSettingsClean' import { @@ -131,7 +131,7 @@ export async function getTaggedSectionData( const allowedFolders = getFoldersMatching(includedFolders,false, excludedFolders) // Get allowed teamspaces - const allowedTeamspaceIDs = config.includedTeamspaces ?? ['private'] + const allowedTeamspaceIDs = resolveAllowedTeamspaceIDs(config) for (const n of notesWithTag) { // logTimer('getTaggedSectionData', thisStartTime, `- start of processing for note "${n.filename}"`)