{"record":{"id":"8f34623b65defe43","repo":"koala73/worldmonitor","slug":"quiethoursstart-and-quiethoursend-must-differ-sam","errorCode":null,"errorMessage":"quietHoursStart and quietHoursEnd must differ (same value = no quiet window)","messagePattern":"quietHoursStart and quietHoursEnd must differ \\(same value = no quiet window\\)","errorType":"validation","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":461,"sourceCode":"    if (!identity) throw new ConvexError(\"UNAUTHENTICATED\");\n    const userId = identity.subject;\n    await assertProEntitlement(ctx, userId);\n    validateQuietHoursArgs(args);\n\n    const existing = await ctx.db\n      .query(\"alertRules\")\n      .withIndex(\"by_user_variant\", (q) =>\n        q.eq(\"userId\", userId).eq(\"variant\", args.variant),\n      )\n      .unique();\n\n    // Only enforce start !== end when quiet hours are effectively enabled\n    const effectiveEnabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;\n    if (effectiveEnabled) {\n      const effectiveStart = args.quietHoursStart ?? existing?.quietHoursStart;\n      const effectiveEnd = args.quietHoursEnd ?? existing?.quietHoursEnd;\n      if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) {\n        throw new ConvexError(\"quietHoursStart and quietHoursEnd must differ (same value = no quiet window)\");\n      }\n    }\n\n    // resolveEffectivePair supplies sensitivity:'critical' on fresh insert (compatible\n    // by construction under the tightened rule). We DO NOT call assertCompatibleDeliveryMode here — quiet-hours\n    // mutations don't touch the (digestMode, sensitivity) pair, so blocking unrelated\n    // quiet-hours updates on pre-migration forbidden rows would surface as confusing\n    // generic 500s ('set-quiet-hours' HTTP action has no INCOMPATIBLE_DELIVERY\n    // passthrough). The relay coerce-at-read protects delivery for in-flight forbidden\n    // rows; the migration drains them.\n    // See docs/archive/plans/forbid-realtime-all-events.md + PR #3461 Greptile P1.\n    const pair = resolveEffectivePair({ existing: existing ?? undefined });\n\n    const now = Date.now();\n    const patch = {\n      quietHoursEnabled: args.quietHoursEnabled,\n      quietHoursStart: args.quietHoursStart,\n      quietHoursEnd: args.quietHoursEnd,","sourceCodeStart":443,"sourceCodeEnd":479,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/alertRules.ts#L443-L479","documentation":"setQuietHours rejects an effective start === end because identical bounds denote an empty window (convex/alertRules.ts:457-462). The check uses EFFECTIVE values: args.quietHoursStart ?? existing row value, same for end and for quietHoursEnabled — so it fires even when you pass only one bound that happens to equal the stored other bound, and only when quiet hours are effectively enabled. There is deliberately no assertCompatibleDeliveryMode call here (see the in-source comment about pre-migration rows).","triggerScenarios":"setQuietHours with quietHoursEnabled true and quietHoursStart === quietHoursEnd (e.g. both 22); enabling quiet hours (quietHoursEnabled: true) on a row whose stored start and end are already equal; patching only quietHoursEnd to a value equal to the stored quietHoursStart.","commonSituations":"Forms defaulting both selects to the same hour (22/22); a user attempting 'do not disturb all day' by setting start = end — the feature models this via quietHoursEnabled, not equal bounds; partial updates that unknowingly collide with stored values.","solutions":["Pick different bounds — an overnight window like start 22, end 7 is valid (end < start crosses midnight).","To disable the window, pass quietHoursEnabled: false instead of collapsing start and end to the same hour.","When patching a single bound, first read the stored rule (getAlertRules) and ensure the resulting pair differs.","Send both bounds explicitly on enable-flows so the effective pair is deterministic."],"exampleFix":"// before\nawait mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 22 });\n// ConvexError: quietHoursStart and quietHoursEnd must differ (same value = no quiet window)\n\n// after — overnight window; or disable when no window is wanted\nawait mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 7 });\n// no window at all:\nawait mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: false });","handlingStrategy":"validation","validationCode":"// Compare EFFECTIVE bounds (arg ?? stored) exactly like the server.\nconst effectiveStart = form.start ?? storedRule?.quietHoursStart;\nconst effectiveEnd = form.end ?? storedRule?.quietHoursEnd;\nconst effectiveEnabled = form.enabled ?? storedRule?.quietHoursEnabled ?? false;\nif (effectiveEnabled && effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) {\n  setFormError('Start and end must differ; disable quiet hours instead of matching them');\n  return;\n}\nawait mutateAPI.alertRules.setQuietHours(form);","typeGuard":"const isValidQuietWindow = (\n  enabled: boolean,\n  start: number | undefined,\n  end: number | undefined,\n): boolean => !enabled || start === undefined || end === undefined || start !== end;","tryCatchPattern":"try {\n  await mutateAPI.alertRules.setQuietHours(args);\n} catch (err) {\n  if (err instanceof ConvexError && typeof err.data === 'string' && err.data.includes('must differ')) {\n    setFormError('Quiet hours need different start and end times (e.g. 22:00 to 07:00)');\n    return;\n  }\n  throw err;\n}","preventionTips":["Never default both selects to the same hour.","Express 'no quiet window' as quietHoursEnabled: false, not equal bounds.","Read the stored rule before patching a single bound — the check merges arg and stored values.","Allow end < start in the UI (overnight windows) so users don't force equality."],"tags":["convex","validation","cross-field","quiet-hours"],"backgroundTag":"conflicting-field-values","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}