fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
type annotation on mockLastSyncedUid; remove async from test callbacks without
await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
comments land on the exact violation lines; void self.skipWaiting()
This commit is contained in:
@@ -180,11 +180,13 @@ export function expandOccurrences(
|
||||
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(rawVevent)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
|
||||
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
|
||||
@@ -230,7 +232,7 @@ export function expandOccurrences(
|
||||
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
|
||||
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
|
||||
// producing zero-duration occurrences (BUG 1).
|
||||
let occEnd: ICAL.Time = (event.endDate ?? dtstart) as ICAL.Time
|
||||
let occEnd: ICAL.Time = event.endDate ?? dtstart
|
||||
|
||||
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
|
||||
if (!allDay && occEnd.compare(dtstart) <= 0) {
|
||||
|
||||
@@ -372,7 +372,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
rruleFromPayload,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay as boolean,
|
||||
fields.allDay,
|
||||
)
|
||||
: undefined
|
||||
} else if (preservedRrule) {
|
||||
@@ -383,7 +383,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
strippedPreset,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay as boolean,
|
||||
fields.allDay,
|
||||
)
|
||||
} else {
|
||||
finalRruleString = preservedRrule
|
||||
@@ -394,12 +394,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
|
||||
const { icsString } = buildVeventString({
|
||||
uid: row.uid,
|
||||
summary: fields.title as string,
|
||||
allDay: fields.allDay as boolean,
|
||||
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
|
||||
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
|
||||
location: fields.location as string | undefined,
|
||||
description: fields.description as string | undefined,
|
||||
summary: fields.title,
|
||||
allDay: fields.allDay,
|
||||
dtstart: fields.allDay ? fields.start : new Date(fields.start),
|
||||
dtend: fields.allDay ? fields.end : new Date(fields.end),
|
||||
location: fields.location,
|
||||
description: fields.description,
|
||||
rruleString: finalRruleString,
|
||||
})
|
||||
|
||||
@@ -463,7 +463,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
rruleFromPayload,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay as boolean,
|
||||
fields.allDay,
|
||||
)
|
||||
: undefined
|
||||
} else if (preservedRrule) {
|
||||
@@ -474,7 +474,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
strippedPreset,
|
||||
fields.recurrenceUntil,
|
||||
fields.recurrenceCount,
|
||||
fields.allDay as boolean,
|
||||
fields.allDay,
|
||||
)
|
||||
} else {
|
||||
finalRruleString = preservedRrule
|
||||
@@ -485,12 +485,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
|
||||
const { icsString } = buildVeventString({
|
||||
uid: row.uid,
|
||||
summary: fields.title as string,
|
||||
allDay: fields.allDay as boolean,
|
||||
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
|
||||
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
|
||||
location: fields.location as string | undefined,
|
||||
description: fields.description as string | undefined,
|
||||
summary: fields.title,
|
||||
allDay: fields.allDay,
|
||||
dtstart: fields.allDay ? fields.start : new Date(fields.start),
|
||||
dtend: fields.allDay ? fields.end : new Date(fields.end),
|
||||
location: fields.location,
|
||||
description: fields.description,
|
||||
rruleString: finalRruleString,
|
||||
})
|
||||
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function runPoll(): Promise<void> {
|
||||
|
||||
// ctag/syncToken: defensive null handling (Pitfall #6)
|
||||
const knownCtag = stored?.ctag ?? null
|
||||
const currentCtag = (davCal.ctag ?? davCal.syncToken ?? null) as string | null
|
||||
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null
|
||||
|
||||
// Skip if ctag is present on both sides and unchanged
|
||||
if (currentCtag !== null && currentCtag === knownCtag) {
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
||||
if (row.subId != null) {
|
||||
byUid.get(row.uid)!.subs.push({
|
||||
id: row.subId,
|
||||
userId: row.subUserId!,
|
||||
userId: row.subUserId,
|
||||
endpoint: row.subEndpoint,
|
||||
p256dh: row.subP256dh,
|
||||
auth: row.subAuth,
|
||||
|
||||
@@ -50,7 +50,9 @@ async function main() {
|
||||
for (const cal of calendars) {
|
||||
console.log('---')
|
||||
console.log(` url: ${cal.url}`)
|
||||
console.log(` displayName: ${cal.displayName ?? '(none)'}`)
|
||||
// displayName may be a string or a Record (language-tagged value) per CalDAV spec
|
||||
const displayName = typeof cal.displayName === 'string' ? cal.displayName : JSON.stringify(cal.displayName ?? '(none)')
|
||||
console.log(` displayName: ${displayName}`)
|
||||
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
|
||||
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
|
||||
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
|
||||
|
||||
@@ -86,12 +86,14 @@ export async function syncCalendar(
|
||||
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(obj.data as string)
|
||||
} catch {
|
||||
// Malformed VCALENDAR — skip but do not crash the sync
|
||||
continue
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) continue
|
||||
@@ -198,13 +200,14 @@ export async function syncCalendar(
|
||||
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay')
|
||||
|
||||
// Compare title (SUMMARY)
|
||||
const oldTitle = (oldRow.title ?? null) as string | null
|
||||
const oldTitle = oldRow.title ?? null
|
||||
if (oldTitle !== titleValue) changedFields.push('title')
|
||||
|
||||
// Compare location — extract from old rawVevent for comparison
|
||||
let oldLocation: string | null = null
|
||||
if (oldRow.rawVevent) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value
|
||||
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent))
|
||||
const oldVevent = oldComp.getFirstSubcomponent('vevent')
|
||||
if (oldVevent) {
|
||||
|
||||
@@ -63,10 +63,12 @@ export const RRULE_PRESETS: Record<string, string> = {
|
||||
export function extractRruleString(rawVevent: string): string | undefined {
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||
parsed = ICAL.parse(rawVevent)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) return undefined
|
||||
|
||||
@@ -601,7 +601,7 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
}
|
||||
|
||||
// Build the update payload — single-field write with updatedAt=NOW()
|
||||
let updateValues: {
|
||||
const updateValues: {
|
||||
checked?: boolean
|
||||
text?: string
|
||||
rank?: string
|
||||
|
||||
@@ -93,12 +93,18 @@ sseRouter.get('/lists', async (c) => {
|
||||
|
||||
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
|
||||
for (const listId of accessibleListIds) {
|
||||
const unsub = subscribeListEvents(listId, async (event) => {
|
||||
if (stream.aborted) return
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
const unsub = subscribeListEvents(listId, (event) => {
|
||||
// The handler signature is void-returning; wrap the async write in void+catch.
|
||||
// writeSSE errors are non-fatal — the SSE loop detects stream.aborted and cleans up.
|
||||
void (async () => {
|
||||
if (stream.aborted) return
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
})
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[sse] writeSSE error:', err)
|
||||
})
|
||||
})
|
||||
unsubscribers.push(unsub)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
// We import after env manipulation since devAuthBypass() reads env vars at call time.
|
||||
|
||||
@@ -32,7 +32,7 @@ const mockCredentialsSelectResult: Array<{
|
||||
|
||||
// Each call to db.select() needs to return different chains
|
||||
// We use a call counter to decide which data to return
|
||||
let selectCallCount = 0
|
||||
let _callCount = 0
|
||||
|
||||
const mockSelectLimit = vi.fn()
|
||||
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit })
|
||||
@@ -44,7 +44,7 @@ mockSelectFrom.mockImplementation(() => ({
|
||||
where: mockSelectWhere,
|
||||
// Support both: direct await (no where) and .where().limit()
|
||||
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
|
||||
selectCallCount++
|
||||
_callCount++
|
||||
resolve(mockCredentialsSelectResult)
|
||||
return Promise.resolve(mockCredentialsSelectResult)
|
||||
},
|
||||
@@ -71,7 +71,7 @@ vi.mock('../../src/broker/client.js', () => ({
|
||||
describe('broker poller — runPoll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
selectCallCount = 0
|
||||
_callCount = 0
|
||||
|
||||
// Reset implementations
|
||||
mockSyncCalendar.mockResolvedValue(undefined)
|
||||
|
||||
@@ -518,7 +518,7 @@ describe('GET /api/events/sync-status', () => {
|
||||
const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||
const mockLimit = vi.fn().mockReturnValue(mockOrderBy)
|
||||
const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
||||
const mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||
const _mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||
// Some implementations use .where().orderBy() or just .where()
|
||||
mockSimpleWhere.mockReturnValue({ limit: mockLimit, orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }) })
|
||||
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
||||
|
||||
@@ -510,7 +510,6 @@ async function seedItem(
|
||||
rank: string,
|
||||
checked = false,
|
||||
): Promise<number> {
|
||||
const { eq } = await import('drizzle-orm')
|
||||
const [result] = await db.insert(listItems).values({ listId, text, rank, checked }).$returningId()
|
||||
return result.id
|
||||
}
|
||||
@@ -646,7 +645,7 @@ describe('PATCH /api/list-items/:id — per-field LWW (D-08)', () => {
|
||||
const listId = await seedList(ownerId, 'Uncheck Rank', false)
|
||||
// Two active items
|
||||
await seedItem(listId, 'alpha', 'a0')
|
||||
const lastActiveId = await seedItem(listId, 'beta', 'a1')
|
||||
await seedItem(listId, 'beta', 'a1')
|
||||
// One checked item (to uncheck)
|
||||
const checkedItemId = await seedItem(listId, 'checked-one', 'Zz', true)
|
||||
|
||||
@@ -1055,8 +1054,8 @@ describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03,
|
||||
const ownerId = await seedUser('reorder-lww')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder LWW', false)
|
||||
const id1 = await seedItem(listId, 'alpha', 'a0')
|
||||
const id2 = await seedItem(listId, 'beta', 'a1')
|
||||
const _id1 = await seedItem(listId, 'alpha', 'a0')
|
||||
const _id2 = await seedItem(listId, 'beta', 'a1')
|
||||
const id3 = await seedItem(listId, 'gamma', 'a2')
|
||||
|
||||
const app = await getApp()
|
||||
|
||||
Reference in New Issue
Block a user