mirror of
https://github.com/formax68/memoChron.git
synced 2026-07-22 12:30:31 +00:00
docs(08): add code review report
This commit is contained in:
parent
4bd1cb93ff
commit
e2ea050e7e
1 changed files with 548 additions and 0 deletions
548
.planning/phases/08-type-hygiene-conventions/08-REVIEW.md
Normal file
548
.planning/phases/08-type-hygiene-conventions/08-REVIEW.md
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
---
|
||||
phase: 08-type-hygiene-conventions
|
||||
reviewed: 2026-05-17T00:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 15
|
||||
files_reviewed_list:
|
||||
- eslint.config.mjs
|
||||
- src/main.ts
|
||||
- src/services/CalendarService.ts
|
||||
- src/services/IcsImportService.ts
|
||||
- src/services/NoteService.ts
|
||||
- src/settings/SettingsTab.ts
|
||||
- src/settings/types.ts
|
||||
- src/types/ical.d.ts
|
||||
- src/utils/constants.ts
|
||||
- src/utils/pathUtils.ts
|
||||
- src/utils/timezoneUtils.ts
|
||||
- src/utils/viewRenderers.ts
|
||||
- src/views/CalendarView.ts
|
||||
- src/views/EmbeddedAgendaView.ts
|
||||
- src/views/EmbeddedCalendarView.ts
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 7
|
||||
info: 8
|
||||
total: 17
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 8: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-05-17T00:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 15
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 8 closes the v1.15 directory-scorecard for type hygiene (DIR-09/DIR-10), console gating (DIR-01), and documentation (DOC-02). The mechanical work is sound: the `DEBUG = false` gate pattern is correctly dead-code-eliminated by esbuild's tree-shaker in production builds, and `(window as any).moment` has been fully replaced with the typed `import { moment } from "obsidian"`. The `**/*.d.ts no-explicit-any: off` and the `src/**/*.ts no-unsafe-*: off` overrides are scoped narrowly enough.
|
||||
|
||||
However, the review surfaces two **Critical** behavioral defects that ride on the explicit-any/unsafe cascade — both pre-date Phase 8 but become more visible now that `no-unsafe-*` is silenced and the surface looks clean. There are also seven **Warnings** flagging error-visibility regressions from the DIR-01 console cleanup (Plan 03), narrow ical.js consumption sites that lost runtime validation when the `no-unsafe-*` block went in, and one logic bug in attendee filtering.
|
||||
|
||||
The `no-unsafe-*` override block in Plan 04 is scoped to `src/**/*.ts` (not the whole project) and the ical.js consumption sites have basic runtime validation in most places — but two sites (`isValidCache` cache deserialization and `getEventsForEmbed` source filtering) have gaps that the lint silence now hides.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: `getEventsForEmbed` / `getAllEventsForEmbed` bypass `enabled` filter when `calendarNames` is provided
|
||||
|
||||
**File:** `src/services/CalendarService.ts:140-161`, `src/services/CalendarService.ts:168-189`
|
||||
|
||||
**Issue:** When a `memochron-agenda` or `memochron-calendar` code block names calendars explicitly via the `calendars:` parameter, the filtering logic walks only the case-insensitive name match:
|
||||
|
||||
```ts
|
||||
if (calendarNames && calendarNames.length > 0) {
|
||||
const lowerNames = calendarNames.map((n) => n.toLowerCase());
|
||||
filteredEvents = filteredEvents.filter((event) =>
|
||||
lowerNames.includes(event.source.toLowerCase())
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `else` branch correctly filters by `source.enabled && source.showInEmbeds !== false`, but the `calendarNames` branch does NOT consult `source.enabled` at all. Events from a calendar that the user has explicitly DISABLED in settings still render in any embed that names that calendar.
|
||||
|
||||
This breaks the documented invariant ("Disable a calendar to stop fetching/showing its events") and is a privacy regression — a user who disables a sensitive work calendar to hide it on a shared screen will still see its events in an embedded code block. Note that `this.events` may still contain the disabled calendar's events from the last fetch before disabling (cache-write doesn't purge events when a source flips off; see CalendarService.performFetch which only re-fetches from `enabledSources`, but `hasSourceMismatch` lazily re-fetches on the next call — until then, stale events remain).
|
||||
|
||||
**Fix:** Intersect both filters in the `calendarNames` branch:
|
||||
|
||||
```ts
|
||||
if (calendarNames && calendarNames.length > 0) {
|
||||
const lowerNames = calendarNames.map((n) => n.toLowerCase());
|
||||
const enabledNames = new Set(
|
||||
this.plugin.settings.calendarUrls
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => s.name.toLowerCase())
|
||||
);
|
||||
filteredEvents = filteredEvents.filter((event) => {
|
||||
const lowered = event.source.toLowerCase();
|
||||
return lowerNames.includes(lowered) && enabledNames.has(lowered);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same fix to `getAllEventsForEmbed` (lines 168-189).
|
||||
|
||||
---
|
||||
|
||||
### CR-02: `isValidCache` accepts malformed event payloads, leading to silent event loss
|
||||
|
||||
**File:** `src/services/CalendarService.ts:317-324`, `src/services/CalendarService.ts:326-334`
|
||||
|
||||
**Issue:** The cache validator only verifies `timestamp: number` and `events: Array`. After Plan 02 migrated the cast to `cache: unknown` (good), the inner shape of `cache.events` is still untrusted:
|
||||
|
||||
```ts
|
||||
private isValidCache(cache: unknown): cache is CacheData {
|
||||
if (!cache || typeof cache !== "object") return false;
|
||||
const c = cache as Record<string, unknown>;
|
||||
return (
|
||||
typeof c.timestamp === "number" &&
|
||||
Array.isArray(c.events)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`restoreFromCache` then iterates blindly:
|
||||
|
||||
```ts
|
||||
cache.events.forEach((event) => {
|
||||
event.start = new Date(event.start); // new Date("garbage") => Invalid Date
|
||||
event.end = new Date(event.end);
|
||||
});
|
||||
this.events = cache.events;
|
||||
```
|
||||
|
||||
A corrupted, partially-written, or downgrade-incompatible `calendar-cache.json` (the cache file lives under `.obsidian/plugins/memochron/calendar-cache.json` and survives plugin updates) yields `Invalid Date` values for `start`/`end`. Every downstream date comparison (`event.start <= endOfDay`, `event.end >= startOfDay`, `event.end < now`) silently evaluates `false` because `NaN` comparisons return `false`. Result: the user sees zero events, no error notice, no console message (the catch on line 302 is `DEBUG`-gated and `DEBUG = false` in production). The user has no path to recover except to delete the cache file manually.
|
||||
|
||||
This is a data-loss-shaped bug (loss of visibility, not corruption) made worse by Plan 03's console cleanup, because the cache-decode failure path went from `console.log(...)` to `if (DEBUG) console.debug(...)` — there is now NO production diagnostic when the cache deserializes badly.
|
||||
|
||||
**Fix:** Validate per-event shape and reject the cache instead of silently using bad data:
|
||||
|
||||
```ts
|
||||
private isValidCache(cache: unknown): cache is CacheData {
|
||||
if (!cache || typeof cache !== "object") return false;
|
||||
const c = cache as Record<string, unknown>;
|
||||
if (typeof c.timestamp !== "number") return false;
|
||||
if (!Array.isArray(c.events)) return false;
|
||||
return c.events.every((e) => {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
const ev = e as Record<string, unknown>;
|
||||
return (
|
||||
typeof ev.id === "string" &&
|
||||
typeof ev.title === "string" &&
|
||||
typeof ev.source === "string" &&
|
||||
typeof ev.sourceId === "string" &&
|
||||
(typeof ev.start === "string" || ev.start instanceof Date) &&
|
||||
(typeof ev.end === "string" || ev.end instanceof Date) &&
|
||||
!isNaN(new Date(ev.start as string | Date).getTime()) &&
|
||||
!isNaN(new Date(ev.end as string | Date).getTime())
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, the `catch (error)` on CalendarService.ts:301-302 should at minimum log unconditionally (not `DEBUG`-gated) when the cache file exists but fails to deserialize — silent recovery is fine for "no cache file" but not for "cache file is broken."
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Per-calendar `filteredCuTypes` overrides cannot express "include nothing"
|
||||
|
||||
**File:** `src/services/CalendarService.ts:810-813`
|
||||
|
||||
**Issue:** The override falls back to the global default using `||`, not `??`:
|
||||
|
||||
```ts
|
||||
const filteredCuTypes =
|
||||
(calendarSettings?.filteredCuTypes) ||
|
||||
this.plugin.settings.filteredCuTypes;
|
||||
```
|
||||
|
||||
If a user customizes a calendar to filter out ALL attendee CUTYPEs (an empty array `[]`), the `||` operator coerces `[]`... wait — `[]` is truthy in JS, so `[] || X` returns `[]`. So this is actually safe for the empty-array case. BUT: if a user clears the per-calendar override and the stored value is `null` from a migration, the global wins — which is probably desired. The bigger latent bug is that `IcsImportService.extractAttendees` (line 106) uses `||` against `["INDIVIDUAL", ""]` as the hardcoded default, meaning a caller passing an empty array intentionally gets the default applied. This is inconsistent with CalendarService's behavior and surprising at a call site that no longer has TypeScript safety help.
|
||||
|
||||
**Fix:** Use `??` consistently in both places, and document that empty array means "include nothing":
|
||||
|
||||
```ts
|
||||
// CalendarService.ts
|
||||
const filteredCuTypes =
|
||||
calendarSettings?.filteredCuTypes ?? this.plugin.settings.filteredCuTypes;
|
||||
|
||||
// IcsImportService.ts
|
||||
const cuTypeFilter = filteredCuTypes ?? ["INDIVIDUAL", ""];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-02: DIR-01 console cleanup silently swallows non-403/404 calendar fetch errors
|
||||
|
||||
**File:** `src/services/CalendarService.ts:386-392`, `src/services/CalendarService.ts:402-415`
|
||||
|
||||
**Issue:** Plan 03's `if (DEBUG) console.error(...)` gating means that in production builds (`DEBUG = false`), the following error paths produce ZERO user-visible or developer-visible output:
|
||||
|
||||
1. Line 387-391 (`else` branch for non-403/404 HTTP failure): the body is `if (DEBUG) console.error(...)`. A 500, 502, 503, 401, 429, etc., now produces neither a Notice nor a console line. The user sees their calendar silently fail to refresh.
|
||||
2. Line 405: `if (DEBUG) console.error('Error fetching calendar ...', message);` — runs only when DEBUG is true. The function then checks `message.includes('CORS')` and `message.includes('network')` to show specific Notices, but EVERY other thrown error (e.g., "ERR_CERT_AUTHORITY_INVALID", "ETIMEDOUT", "ENOTFOUND") produces no Notice and no console output.
|
||||
|
||||
The Phase-3 SUMMARY notes 33 console statements were deleted and 6 gated — but these two were gated, not surfaced as Notices. The result is a regression from "noisy logs the user can grep for" to "completely silent failure."
|
||||
|
||||
**Fix:** Add a generic fallback Notice for unmatched error categories, and unconditionally log the error so developers diagnosing user reports can ask the user to check the dev console:
|
||||
|
||||
```ts
|
||||
// fetchCalendar catch block
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
console.error(`MemoChron: Failed to fetch calendar "${source.name}":`, message);
|
||||
|
||||
if (message.includes('CORS')) {
|
||||
new Notice(`MemoChron: Calendar "${source.name}" blocked by CORS policy.`);
|
||||
} else if (message.includes('network')) {
|
||||
new Notice(`MemoChron: Network error fetching calendar "${source.name}".`);
|
||||
} else {
|
||||
new Notice(`MemoChron: Failed to fetch calendar "${source.name}". Check console for details.`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
Apply equivalent treatment to the non-403/404 status branch on line 387-391. The DIR-01 rule prohibits *unconditional* console statements, but error paths should still surface to the user — the appropriate fix is a Notice, not a DEBUG-gated console call.
|
||||
|
||||
---
|
||||
|
||||
### WR-03: `fetchInFlight` deduplication ignores source-set changes mid-fetch
|
||||
|
||||
**File:** `src/services/CalendarService.ts:55-87`
|
||||
|
||||
**Issue:** The in-flight short-circuit (BUG-06/D-12) runs BEFORE the source-mismatch check:
|
||||
|
||||
```ts
|
||||
if (this.fetchInFlight) {
|
||||
return this.fetchInFlight;
|
||||
}
|
||||
// ...
|
||||
if (!this.needsRefresh(enabledSources, forceRefresh)) {
|
||||
return this.events;
|
||||
}
|
||||
```
|
||||
|
||||
If caller A starts a fetch with sources `[X, Y]`, and while it's running the user adds calendar `Z` in settings (triggering `saveSettings → refreshCalendarView → fetchCalendars(newSources)`), caller B with sources `[X, Y, Z]` receives caller A's in-flight promise — which only fetches `[X, Y]`. The mismatch will be detected on the NEXT `fetchCalendars` call (via `hasSourceMismatch`), but the user-triggered refresh appears to "do nothing" — the Notice shows complete with the OLD event count.
|
||||
|
||||
Worse, the `Notice` on completion (`showCompletionNotification`) reports `this.events.length` — which is the count from caller A's fetch, not caller B's expected fetch. If the user added a calendar with 100 events, the Notice still shows the old count.
|
||||
|
||||
**Fix:** Detect the source-set change and chain a follow-up fetch after the in-flight one settles, or invalidate the in-flight cache for `forceRefresh=true`:
|
||||
|
||||
```ts
|
||||
async fetchCalendars(
|
||||
sources: CalendarSource[],
|
||||
forceRefresh = false
|
||||
): Promise<CalendarEvent[]> {
|
||||
// If the caller forces a refresh OR the source set differs from what's in flight,
|
||||
// do not dedupe — start a fresh fetch chained after the in-flight one settles.
|
||||
if (this.fetchInFlight && !forceRefresh && !this.hasSourceMismatch(
|
||||
sources.filter((s) => s.enabled && s.url?.trim())
|
||||
)) {
|
||||
return this.fetchInFlight;
|
||||
}
|
||||
// ... rest as before
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-04: `restoreFromCache` mutates input then aliases — surprising data ownership
|
||||
|
||||
**File:** `src/services/CalendarService.ts:326-334`
|
||||
|
||||
**Issue:** The method mutates `cache.events[i].start` and `.end` IN PLACE (converting string → Date), then aliases `this.events = cache.events`. The caller is `loadFromCache`, which returns `cacheData.events` — the same array now mutated. If a future refactor caches `cacheData` (e.g., to keep a reference for round-trip diffing), the cached object becomes inconsistent with the file on disk: the on-disk `start`/`end` are ISO strings, the in-memory cache has them as `Date` objects pretending to be the same.
|
||||
|
||||
This is also fragile to a partial cache read: if `JSON.parse` succeeds but a subset of events have malformed `start` values, `new Date(invalid)` produces `Invalid Date` and we hit CR-02's silent-loss path.
|
||||
|
||||
**Fix:** Build a fresh array, validate each event, and reject the cache rather than mutate:
|
||||
|
||||
```ts
|
||||
private restoreFromCache(cache: CacheData): boolean {
|
||||
const restored: CalendarEvent[] = [];
|
||||
for (const event of cache.events) {
|
||||
const start = new Date(event.start);
|
||||
const end = new Date(event.end);
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) return false;
|
||||
restored.push({ ...event, start, end });
|
||||
}
|
||||
this.events = restored;
|
||||
this.lastFetch = cache.timestamp;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WR-05: Code duplication: location-icon logic exists in 4 places, drift risk
|
||||
|
||||
**File:** `src/utils/viewRenderers.ts:332-336`, `src/views/EmbeddedAgendaView.ts:363-367`, `src/views/CalendarView.ts:883-897`, `src/services/NoteService.ts:341-359`
|
||||
|
||||
**Issue:** The same regex-based location classification (URL → 🔗, virtual meeting keywords → 💻, else 📍) is duplicated in four files with subtle variations:
|
||||
|
||||
- `viewRenderers.ts:332-336`: regex literal `^(https?:\/\/|www\.)` and `zoom|meet\.|teams|webex` (case-insensitive flag).
|
||||
- `EmbeddedAgendaView.ts:363-367`: identical to viewRenderers, separately defined.
|
||||
- `CalendarView.ts:883-897`: split across `getLocationIcon`/`isUrl`/`isVirtualMeeting`, uses `toLowerCase()` and `Array.some` instead of the regex flag.
|
||||
- `NoteService.ts:341-359`: `getLocationEmoji`/`isUrl`/`isVirtualMeeting` — substantially the same logic but the keyword list `["zoom", "meet.", "teams", "webex"]` is duplicated again as a local const.
|
||||
|
||||
If a future change adds "google.meet" or "discord" to the virtual-meeting list, four sites need synchronized edits. Phase 8 silenced the `no-unsafe-*` ical cascade, but did not address this earlier-flagged duplication. The Phase-8 mechanical cleanup work makes this duplication MORE visible because the surrounding code now lints clean — drift becomes the dominant maintenance risk.
|
||||
|
||||
**Fix:** Extract a single `getLocationIcon(location: string): string` into `src/utils/locationIcons.ts` (or extend `viewRenderers.ts`) and have all four call sites import it. Same for `parseTags` (duplicated in `SettingsTab` line 1268 and `CalendarNotesSettingsModal` line 1718).
|
||||
|
||||
---
|
||||
|
||||
### WR-06: `IcsImportService` does not unregister VTIMEZONE components — global service pollution
|
||||
|
||||
**File:** `src/services/IcsImportService.ts:25-34`
|
||||
|
||||
**Issue:** `parseSingleEvent` registers every VTIMEZONE component from the dropped ICS file into ical.js's `TimezoneService`, which is a **global singleton**. The comment notes "Silently ignore: timezone may already be registered" but nothing ever unregisters. A user who drags multiple ICS files with conflicting custom VTIMEZONE definitions (e.g., two corporate calendars with their own "Customized Time Zone" rules) gets whichever was registered first — subsequent registrations either silently fail (per the catch) or override depending on ical.js internals.
|
||||
|
||||
This is the same root cause as the `TimezoneService.reset()` need flagged in `ical.d.ts` (line 69). `reset()` exists in the type shim but is never called.
|
||||
|
||||
**Fix:** Either (a) call `TimezoneService.reset()` and re-register from the parsed `comp` at the start of each `parseSingleEvent`, accepting the cost of losing previously-registered zones; OR (b) namespace registered zones with a per-file prefix to avoid collisions; OR (c) document this as a known limitation in the user-facing docs. Option (a) is safest:
|
||||
|
||||
```ts
|
||||
static parseSingleEvent(...) {
|
||||
// ...
|
||||
const vtimezones = comp.getAllSubcomponents("vtimezone");
|
||||
// Note: leaves CalendarService's registrations intact since CalendarService
|
||||
// does not call register() — only IcsImportService does. If that ever changes,
|
||||
// both services need a coordinated reset strategy.
|
||||
vtimezones.forEach((tz) => { try { TimezoneService.register(tz); } catch { /* ... */ } });
|
||||
}
|
||||
```
|
||||
|
||||
The minimum acceptable fix is a code comment documenting that VTIMEZONE registrations leak across imports. The current "silently ignore" comment understates the issue.
|
||||
|
||||
---
|
||||
|
||||
### WR-07: External link in help modal lacks `rel="noopener"`
|
||||
|
||||
**File:** `src/settings/SettingsTab.ts:1934-1938`
|
||||
|
||||
**Issue:**
|
||||
```ts
|
||||
const link = docLink.createEl("a", {
|
||||
text: "View full documentation on GitHub",
|
||||
href: "https://github.com/formax68/memoChron#remote-calendars",
|
||||
});
|
||||
link.setAttr("target", "_blank");
|
||||
```
|
||||
|
||||
`target="_blank"` without `rel="noopener noreferrer"` allows the opened tab to navigate the opener via `window.opener.location` (reverse-tabnabbing). In Electron with `nodeIntegration` disabled this is partially mitigated, but Obsidian renders external links via the system browser and the rendered HTML still sets `window.opener`. Best practice is to set `rel`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```ts
|
||||
link.setAttr("target", "_blank");
|
||||
link.setAttr("rel", "noopener noreferrer");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Unused exports in `src/utils/constants.ts`
|
||||
|
||||
**File:** `src/utils/constants.ts:6,17-26,29-37`
|
||||
|
||||
**Issue:** Several exports have no callers anywhere in `src/`:
|
||||
- `DEFAULT_VIEW` (line 6) — never imported.
|
||||
- `CALENDAR_COLOR_PALETTE` (lines 17-26) — never imported.
|
||||
- `CUTYPE_INDIVIDUAL`, `CUTYPE_GROUP`, `CUTYPE_RESOURCE`, `CUTYPE_ROOM`, `CUTYPE_UNKNOWN` (lines 29-33) — never imported as constants; the actual strings are inlined in `SettingsTab.ts:1041-1047`.
|
||||
- `DEFAULT_FILTERED_CUTYPES` (line 37) is the only CUTYPE constant used externally.
|
||||
|
||||
Phase 8's DIR-10 work closed 18 `no-unused-vars` sites, but unused exports require `tsc --noUnusedLocals` + `--noUnusedParameters` (and even then, exports aren't flagged) or a tool like `ts-prune`. Leftover dead exports inflate the public surface and tempt future drift (a new contributor may use `CUTYPE_ROOM` from constants while the existing checkbox UI uses an inlined `"ROOM"` — three months later they're different strings).
|
||||
|
||||
**Fix:** Delete `DEFAULT_VIEW` and `CALENDAR_COLOR_PALETTE`. Replace the inlined `"INDIVIDUAL"`/`"GROUP"`/etc. strings in `SettingsTab.ts:1041-1047` with the constants — or delete the unused constants.
|
||||
|
||||
---
|
||||
|
||||
### IN-02: Per-file `const DEBUG = false` declarations are out of sync risk
|
||||
|
||||
**File:** `src/services/CalendarService.ts:17`, `src/utils/timezoneUtils.ts:7`
|
||||
|
||||
**Issue:** Plan 03 introduced `const DEBUG = false` independently in two files. The same flag controls different diagnostic outputs in different modules. A developer debugging a calendar issue who flips `CalendarService.ts:17` to `true` will get fetch/cache logs but no timezone logs unless they also remember to flip `timezoneUtils.ts:7`. Conversely, leaving one accidentally at `true` ships verbose logs in a release build.
|
||||
|
||||
**Fix:** Centralize into `src/utils/debug.ts`:
|
||||
|
||||
```ts
|
||||
// Single source of truth for forensic-only logging.
|
||||
// Production builds: flag must be `false`. esbuild tree-shakes `if (DEBUG) ...`.
|
||||
export const DEBUG = false;
|
||||
```
|
||||
|
||||
Then import in both consumers. The build-time elimination still works because the import resolves to a module-level `const` initializer.
|
||||
|
||||
---
|
||||
|
||||
### IN-03: `viewRenderers.getReorderedWeekdays` silently breaks on out-of-range `firstDayOfWeek`
|
||||
|
||||
**File:** `src/utils/viewRenderers.ts:226-229`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```ts
|
||||
function getReorderedWeekdays(firstDay: number): string[] {
|
||||
const weekdays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return [...weekdays.slice(firstDay), ...weekdays.slice(0, firstDay)];
|
||||
}
|
||||
```
|
||||
|
||||
If `firstDay` is `7` (or any out-of-range value), `slice(7)` returns `[]` and `slice(0, 7)` returns the full array — so the function happens to round-trip and produce a valid week. But if `firstDay = -1` or `firstDay = NaN`, `slice` semantics differ and the rendered weekdays misalign with the day grid. The settings dropdown clamps to 0-6 (`SettingsTab.ts:101-109`), so today there is no path to a bad value — but settings restored from a corrupted `data.json` (the same threat model as SEC-01's color validation in `main.ts:110-121`) could inject a bad number.
|
||||
|
||||
**Fix:** Clamp at the function boundary:
|
||||
|
||||
```ts
|
||||
function getReorderedWeekdays(firstDay: number): string[] {
|
||||
const fd = Math.max(0, Math.min(6, Math.floor(firstDay) || 0));
|
||||
const weekdays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
return [...weekdays.slice(fd), ...weekdays.slice(0, fd)];
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same clamp to `CalendarView.getReorderedWeekdays` (line 508-512) and `getMonthInfo`/`getStartOfWeek` (they all consume `firstDayOfWeek`).
|
||||
|
||||
---
|
||||
|
||||
### IN-04: `getEnabledSourcesForCache` filter differs from elsewhere
|
||||
|
||||
**File:** `src/services/CalendarService.ts:361-365`
|
||||
|
||||
**Issue:** Fetch logic filters with `source.enabled && source.url?.trim()` (lines 60, 198). The cache-write writes only `source.enabled`:
|
||||
|
||||
```ts
|
||||
private getEnabledSourcesForCache() {
|
||||
return this.plugin.settings.calendarUrls
|
||||
.filter((source) => source.enabled)
|
||||
.map((source) => ({ url: source.url, name: source.name }));
|
||||
}
|
||||
```
|
||||
|
||||
This embeds enabled-but-blank-URL entries in `cache.sources`. The current `isValidCache` does not check `cache.sources` at all (see CR-02), so this is silently inert today — but if a future validator starts comparing `cache.sources` against current settings, the asymmetry will produce false positives.
|
||||
|
||||
**Fix:** Apply the same filter:
|
||||
|
||||
```ts
|
||||
.filter((source) => source.enabled && source.url?.trim())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IN-05: `SettingsTab.renderRefreshInterval` silently truncates fractional input
|
||||
|
||||
**File:** `src/settings/SettingsTab.ts:850-880`
|
||||
|
||||
**Issue:** `parseInt(value)` on `"5.5"` returns `5`. The validator reports valid, the truncated value is saved. The user has no indication their `0.5`-minute precision was lost. Probably benign for refresh intervals (minute-granularity is sensible) but inconsistent with the validation message "Refresh interval must be a number" — `"abc"` is rejected but `"5.5min"` is silently accepted as `5`.
|
||||
|
||||
**Fix:** Use `Number(value)` and check `Number.isInteger`:
|
||||
|
||||
```ts
|
||||
const interval = Number(value);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
return { valid: false, error: "Refresh interval must be a positive whole number of minutes" };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IN-06: Duplicate folder-template variable construction logic
|
||||
|
||||
**File:** `src/services/NoteService.ts:471-533`, `src/settings/SettingsTab.ts:1185-1251`
|
||||
|
||||
**Issue:** `NoteService.getFolderTemplateVariables` and `SettingsTab.generatePreviewPath` build the same `{YYYY, YY, MM, M, MMM, MMMM, DD, D, DDD, DDDD, Q, source, event_title}` lookup table. The month-names and day-names arrays are duplicated literally. A future change adding `WW` (week-of-year) or `H`/`HH` (hour) needs synchronized edits to two places, and the settings-preview will silently fall behind the runtime.
|
||||
|
||||
**Fix:** Extract `getFolderTemplateVariables(date: Date, source: string, event_title: string)` into `src/utils/folderTemplate.ts` and call from both sites.
|
||||
|
||||
---
|
||||
|
||||
### IN-07: `CalendarView.checkDailyNoteForDate` swallows non-existent-plugin errors
|
||||
|
||||
**File:** `src/views/CalendarView.ts:153-167`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```ts
|
||||
private checkDailyNoteForDate(date: Date): boolean {
|
||||
if (!appHasDailyNotesPluginLoaded()) return false;
|
||||
try {
|
||||
const momentDate = moment(date);
|
||||
const allDailyNotes = getAllDailyNotes();
|
||||
const dailyNote = getDailyNote(momentDate, allDailyNotes);
|
||||
return dailyNote !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `getAllDailyNotes()` throws (e.g., due to a malformed Daily Notes plugin config that leaves a TFile that's actually a folder, or an invalid moment format string), every grid cell silently reports "no daily note exists" and the indicator dot disappears. The user sees a calendar that LOOKS correct but has lost data. The DIR-01 cleanup turned what was probably a `console.warn` into a silent catch.
|
||||
|
||||
This is called once per visible day per render (e.g., 35-42 times per renderCalendar). Throwing once per call is fine, but unconditionally returning `false` hides legitimate state.
|
||||
|
||||
**Fix:** Cache the all-daily-notes lookup at render start and log once:
|
||||
|
||||
```ts
|
||||
private dailyNotesCache: Record<string, TFile> | null = null;
|
||||
|
||||
private refreshDailyNotesCache(): void {
|
||||
if (!appHasDailyNotesPluginLoaded()) {
|
||||
this.dailyNotesCache = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.dailyNotesCache = getAllDailyNotes() as Record<string, TFile>;
|
||||
} catch (error) {
|
||||
console.error("MemoChron: Failed to load daily notes:", errorMessage(error));
|
||||
this.dailyNotesCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
private checkDailyNoteForDate(date: Date): boolean {
|
||||
if (!this.dailyNotesCache) return false;
|
||||
try {
|
||||
const momentDate = moment(date);
|
||||
return getDailyNote(momentDate, this.dailyNotesCache) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call `refreshDailyNotesCache()` at the start of `renderCalendar()`. This both improves performance (one `getAllDailyNotes()` per render instead of one per day cell) AND surfaces the error path once.
|
||||
|
||||
---
|
||||
|
||||
### IN-08: `ical.d.ts` still uses `any` despite Phase 8's no-explicit-any closure
|
||||
|
||||
**File:** `src/types/ical.d.ts:3,8,12,59,72`
|
||||
|
||||
**Issue:** The Phase 8 plan correctly excluded `.d.ts` files from the `no-explicit-any` rule (`eslint.config.mjs:69-71`), and the shim contains:
|
||||
|
||||
```ts
|
||||
constructor(jCal: any);
|
||||
getFirstPropertyValue(name: string): any;
|
||||
getFirstValue(): any;
|
||||
static fromData(data: any): Timezone;
|
||||
export function parse(input: string): any;
|
||||
```
|
||||
|
||||
These remain `any`. The Phase 8 SUMMARY notes hand-typing is deferred to FRAG-02. That's acceptable, but the cost is that the `src/**/*.ts no-unsafe-*: off` override silences the cascade across the entire `src/` tree — not just at ical-consumption sites. Any future developer can call any method on any value and the lint will be silent. The exclusion is broader than necessary.
|
||||
|
||||
The escape hatch is to do per-call-site `// eslint-disable-next-line` comments, but that's noisy. A middle ground: type each ical.js export's return value as `unknown` rather than `any` in the shim, forcing consumers to narrow at call sites:
|
||||
|
||||
```ts
|
||||
getFirstPropertyValue(name: string): unknown;
|
||||
getFirstValue(): unknown;
|
||||
export function parse(input: string): unknown;
|
||||
```
|
||||
|
||||
This trades a one-time wave of narrowing work for permanent runtime safety on every cache/calendar deserialization (see CR-02). The current trade-off is "no FRAG-02 yet, so accept silence everywhere" — a `unknown`-based shim would let Phase 8 close fully without the broad `src/**/*.ts` override.
|
||||
|
||||
**Fix:** Either follow through with FRAG-02 hand-typing on the ~6 ical.js APIs actually consumed, or replace `any` with `unknown` in `ical.d.ts` and remove the `src/**/*.ts no-unsafe-*: off` override block from `eslint.config.mjs:82-91`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-05-17T00:00:00Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
Loading…
Reference in a new issue