Skip to content

Conversation

@trevwilson
Copy link
Owner

@trevwilson trevwilson commented Jan 21, 2026

Summary by CodeRabbit

  • New Features

    • Full recurring-task support: recurrence editor/field, natural‑language parsing, scheduling utilities, automatic next‑occurrence creation, frontend hooks, and new backend recurrence APIs + PowerSync token endpoint.
  • Bug Fixes

    • PostgreSQL startup now uses a 60s retry/timeout loop to avoid hangups.
  • Documentation

    • Added detailed offline‑first data flow docs.
  • Tests

    • Extensive recurrence unit & integration tests and updated E2E timing.
  • Chores

    • DB schema/migrations and sync mappings updated; Electron OAuth hardened; build output path made configurable; recurrence styles added.

✏️ Tip: You can customize this high-level summary in your review settings.

trevwilson and others added 2 commits January 20, 2026 22:24
- Add RecurrencePattern entity with Fixed (calendar-based) and Relative
  (completion-based) recurrence types
- Support weekly patterns with specific days, monthly with day-of-month or
  Nth weekday, yearly patterns
- Implement natural language parser for recurrence input (e.g., "every Monday",
  "2 weeks after completion")
- Client-side instance spawning on task completion for offline support
- Add recurrence UI to EditActionModal and ProcessPage
- Include PowerSync sync rules and connector support for RecurrencePatterns
- Add RecurrenceService with full CRUD and sync endpoints
- Add unit tests for RecurrenceService

Known issues to fix in follow-up:
- First action instance not created when pattern is created
- Parser tests not yet added

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Race condition prevention:
- Add check for existing active action before spawning duplicate in completion flow

Frontend improvements:
- Add RecurrenceParser tests (47 tests) for natural language parsing
- Extract RecurrenceField component from EditActionModal and ProcessPage
- Improve sync error message with table, ID, and debugging hint
- Fix schema.config.js customSelect to store clean SQL (prefix added by generator)

Backend fixes:
- Add missing EnergyLevel import in RecurrencePatternDto
- Standardize logging to use {Id} consistently in RecurrenceService

Test fixes:
- Fix FK constraint issues in RecurrenceServiceTests by using null for CurrentActionItemId

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds full recurring-task support across backend and frontend: new RecurrencePattern domain model, DB migration, service and API endpoints (including sync paths), frontend parsing/UI/hooks/components, PowerSync integration updates, Electron OAuth hardening and guards, sync rule/schema updates, docs, tests, and styling.

Changes

Cohort / File(s) Summary
Recurrence — Backend Core
src/OpenGTD/Features/Recurrence/*, src/OpenGTD/Infrastructure/Migrations/*, src/OpenGTD/Shared/Persistence/GTDDbContext.cs, src/OpenGTD/Infrastructure/DependencyInjection.cs, src/OpenGTD/Web/ApiEndpoints.cs
New RecurrencePattern entity, enums, DTOs, validators, EF Core configuration and migration, IRecurrenceService + RecurrenceService, endpoint mappings (CRUD, sync, activate/deactivate). DbContext, DI and API registration updated.
Recurrence — Frontend Core & Types
frontend/src/features/recurrence/*, frontend/src/types/*, frontend/src/features/recurrence/index.ts
New parser, formatters, utils, calculateNextOccurrence, Editor and Field components, hooks for fetch/mutate (create/update/deactivate/reactivate/delete/make/remove recurrence), Zod schemas and TS types/enums, and unit tests for parser/utils.
Action & Processing Integration
frontend/src/features/actions/*, frontend/src/features/processing/ProcessPage.tsx, src/OpenGTD/Features/Actions/*, tests/OpenGTD.E2E/ProcessingFlowTests.cs
ActionItems gain RecurrencePatternId and navigation; UI (EditActionModal, ProcessPage) adds recurrence UI and flows; completion logic updated to transactionally compute/insert next occurrences and update pattern; small E2E test tweak.
PowerSync Connector & Sync Rules
frontend/src/lib/powersync/connector.ts, frontend/src/lib/powersync/schema.config.js, frontend/src/lib/powersync/schema.ts, frontend/scripts/generate-sync-rules.js, powersync/sync-rules.yaml, frontend/src/lib/powersync/syncRules.test.ts
Connector now fetches auth token (Electron/web), supports enum mappings and endDate, uses opData fallback in getFullRecord, adds RecurrencePatterns mapping; sync rules generator supports customSelect and file-backed content; YAML rules extended for recurrence and projection tweaks.
Electron Auth & UI Guards
frontend/electron/auth.ts, frontend/src/components/auth/*, frontend/src/lib/electron.ts
Electron OAuth flow hardened: CSRF state, safeStorage-based encryption key, local callback HTTP server with timeout, explicit error handling; UI pages short-circuit Clerk flows when running in Electron.
API Spec & Frontend Swagger
frontend/src/api/swagger.json
API title updated; new /api/recurrence endpoints, DTOs/schemas, enums and PowerSync token endpoint; many operations retagged.
Build, Tasks & Docker
.vscode/tasks.json, frontend/package.json, docker-compose.yml
ensure-postgres task uses docker compose exec -T postgres with a 60s timeout loop; build:win script uses optional ELECTRON_OUTPUT_DIR; docker-compose mounts ./powersync/sync-rules.yaml into powersync service.
Docs, Tests & Helpers
docs/DATA_FLOW.md, tests/OpenGTD.Tests/Features/Recurrence/*, tests/OpenGTD.Tests/Helpers/*, tests/OpenGTD.E2E/*
Adds DATA_FLOW documentation; RecurrenceService unit tests and helpers; test DB cleanup includes RecurrencePatterns; test utilities extended.
Styling
frontend/src/styles/_features.css
Adds Recurrence Field styles (expanded/collapsed, badges, inline layouts); block duplicated in file.
Misc — Scripts & Generators
frontend/scripts/generate-sync-rules.js, powersync/sync-rules.yaml
Generator supports custom SELECT override and normalizes YAML list entries; sync rules add recurrence pattern projection and composite ids for some projections.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as React UI
    participant LocalDB as Local DB / Queue
    participant Connector as PowerSync Connector
    participant API as GTD API
    participant Cloud as Cloud DB

    User->>UI: Create recurring item + pattern
    UI->>UI: parseRecurrence / build pattern
    UI->>LocalDB: INSERT RecurrencePattern + ActionItem (queue)
    LocalDB->>Connector: upload queued records
    Connector->>Connector: getAuthToken / transform enums & dates
    Connector->>API: POST /api/recurrence (Create)
    API->>Cloud: persist RecurrencePattern
    API-->>Connector: return created ids
    Connector->>LocalDB: update local rows with server ids

    Note over User,UI: On completion of recurring action
    User->>UI: mark ActionItem completed
    UI->>LocalDB: UPDATE ActionItem (completed)
    UI->>LocalDB: READ RecurrencePattern
    UI->>UI: calculateNextOccurrence()
    UI->>LocalDB: INSERT new ActionItem (next occurrence) and UPDATE RecurrencePattern
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feature: recurring tasks #1 — Implements a largely overlapping recurring-tasks feature (RecurrencePattern model, migrations, endpoints, frontend UI/hooks and PowerSync integration); high code-level relevance.

Poem

🐰 I parsed the rules and hopped with glee,

Dates and weekdays whisper next to me,
A little seed becomes a task anew,
PowerSync hums and spreads the news,
The rabbit cheers—repeat, repeat! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Feature: recurring tasks' clearly and concisely summarizes the main change—adding recurring task functionality. It is specific, descriptive, and directly reflects the comprehensive recurrence feature implementation across frontend and backend.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/scripts/generate-sync-rules.js (1)

210-232: Multi-line customSelect can generate invalid YAML.

Only the first line gets YAML indentation; subsequent lines from a multi‑line customSelect can start at column 0, breaking the list item. Normalize indentation for all lines before emission.

✅ Suggested fix (indent all select lines)
 function generateSyncRulesYaml() {
   const selectStatements = [];
+  const indent = '          ';
 
   for (const [entityName, config] of Object.entries(powerSyncConfig.entities)) {
     const schema = schemas[entityName];
@@
-    const selectSql = config.customSelect
+    const selectSql = config.customSelect
       ? config.customSelect.trim()
       : generateSelect(config.tableName, extractFields(schema, config));
     // Ensure proper YAML list format with indentation
-    const select = selectSql.startsWith('- ') ? selectSql : `- ${selectSql}`;
+    const selectLine = selectSql.startsWith('- ') ? selectSql : `- ${selectSql}`;
+    const select = selectLine.replace(/\n/g, `\n${indent}`);
     selectStatements.push({ entityName, select });
   }
 
   const dataSection = selectStatements
-    .map(({ entityName, select }) => `          # ${entityName}\n          ${select}`)
+    .map(({ entityName, select }) => `${indent}# ${entityName}\n${indent}${select}`)
     .join('\n\n');
frontend/src/lib/powersync/connector.ts (1)

183-204: Guard PATCH against partial opData fallback.

When the local row is missing, getFullRecord falls back to opData (line 239). However, PowerSync PATCH operations only contain changed fields (id + deltas), not the full record. Using incomplete opData as a fallback for PATCH will send a partial record to the backend, causing unintended field deletions. PUT operations can safely use the opData fallback since PowerSync PUT contains the full row.

Add an allowPartial option to getFullRecord: allow fallback for PUT/new records, reject or fail fast for PATCH when the local row is missing.

Suggested implementation
-        const fullRecord = await this.getFullRecord(database, table, entry.id, entry.opData)
+        const fullRecord = await this.getFullRecord(database, table, entry.id, entry.opData, { allowPartial: true })
...
-        const fullRecord = await this.getFullRecord(database, table, entry.id, entry.opData)
+        const fullRecord = await this.getFullRecord(database, table, entry.id, entry.opData, { allowPartial: false })
...
-  private async getFullRecord(
+  private async getFullRecord(
     database: AbstractPowerSyncDatabase,
     table: string,
     id: string,
-    opData?: Record<string, unknown>
+    opData?: Record<string, unknown>,
+    options?: { allowPartial?: boolean }
   ): Promise<Record<string, unknown>> {
     const results = await database.getAll<Record<string, unknown>>(`SELECT * FROM ${table} WHERE id = ?`, [id])
-    const result = results[0] ?? opData
+    const result = results[0] ?? (options?.allowPartial ? opData : undefined)
🤖 Fix all issues with AI agents
In @.claude/skills/code-simplifier/SKILL.md:
- Around line 59-61: The fenced code block containing "File:line - What changed
- Why it's simpler" lacks a language identifier; update the block delimiter from
``` to ```text (or another appropriate language) so markdown renderers apply
correct syntax highlighting and the document is compliant with fenced code block
rules — locate the fenced block in SKILL.md that wraps the line "File:line -
What changed - Why it's simpler" and add the language identifier immediately
after the opening backticks.

In @.vscode/tasks.json:
- Around line 4-15: The "ensure-postgres" task uses a POSIX-only wait loop and a
hard-coded container name; update the task (label "ensure-postgres") to use the
service-based command "docker compose exec -T postgres pg_isready -U gtdapp -d
gtdapp" instead of "docker exec gtdapp-postgres", replace the POSIX until/sleep
loop with an OS-aware wrapper (detect PowerShell/Windows vs POSIX shell) or use
a portable retry loop that calls the service-based pg_isready repeatedly, and
add a configurable timeout/maximum retries so the task fails instead of hanging
indefinitely.

In `@frontend/electron/auth.ts`:
- Around line 46-143: The startOAuthFlow flow must validate a nonce/state to
prevent local callback hijack: generate a random state (randomUUID from
node:crypto) in startOAuthFlow, include it as a query param in the authUrl and
persist it locally (e.g., in store or a closure), then when handling the
/callback in startOAuthFlow compare url.searchParams.get('state') to the saved
state before accepting the token (reject if mismatched). Also update the web
side (ElectronAuthPage.tsx) so when it redirects to the provided callback URL it
includes the same state param it received (pass through the state param from the
original auth URL into the redirect query) so the Electron side can validate it.
Ensure startOAuthFlow and ElectronAuthPage.tsx reference the same state name and
remove the stored state after successful validation.
- Around line 17-25: The code hardcodes an encryption key in the Store
initialization (Store<AuthStore> in auth.ts using the encryptionKey
'opengtd-electron-auth'), which exposes tokens at rest; replace this pattern by
removing the static encryptionKey and instead retrieving/storing secrets via an
OS-backed secret store: either use Electron's safeStorage or keytar to persist
the encryption key or, better, move sessionToken storage entirely into the OS
keychain and keep only a non-sensitive reference in the Store; update the
Store<AuthStore> initialization to omit encryptionKey and implement helper
functions (e.g., getSessionToken/setSessionToken) that read/write the token
through safeStorage/keytar and fall back to secure prompts if unavailable.

In `@frontend/package.json`:
- Around line 15-17: The build:win npm script currently hardcodes a WSL absolute
path in the electron-builder call ("build:win") which breaks cross-platform
builds; change the script to remove the
--config.directories.output=/mnt/c/temp/OpenGTD-release flag (or replace it with
a relative path like "dist" or "release"), and if you need a custom output
directory move that setting into the electron-builder config (e.g., package.json
"build" section or a separate electron-builder config file) using
"directories.output": "dist" so the build works on native Windows, WSL, and CI
without relying on /mnt/c paths or shell-specific parameter expansion.

In `@frontend/src/components/auth/ElectronAuthPage.tsx`:
- Around line 23-78: The callback URL validation in handleAuth() currently only
checks hostname and may allow non-HTTP(S) schemes to capture tokens; update the
validation of callbackUrl (the URL constructed in handleAuth) to ensure
url.protocol is "http:" or "https:" and reject other schemes, setting status
'error' and an appropriate error message. Also ensure you wait for the user data
to be fully loaded before redirecting: gate the redirect logic that reads
user?.id (after getToken() and before setting window.location.href) on the
useUser() load flag (e.g., user.isLoaded or a userIsLoaded variable) so you
don't send an empty user ID; if user data isn't loaded, set status to
'signing-in' or an error and return. Ensure these checks live inside the same
async handleAuth() flow that uses getToken({ template: 'electron' }) to avoid
racing.

In `@frontend/src/components/auth/ProtectedLayout.tsx`:
- Around line 54-70: Both async flows can leave loading flags set if the IPC
calls reject — update checkAuth and handleSignIn to wrap the await calls
(isElectronAuthenticated and startElectronOAuth) in try/catch/finally: in try
await the call, in catch set a user-facing error via setError(...) (for
checkAuth set an appropriate message or clear auth), and in finally ensure
setIsLoading(false) (for checkAuth) and setIsSigningIn(false) (for handleSignIn)
always run so the UI cannot remain stuck; preserve existing success-handling
logic inside the try block.

In `@frontend/src/features/actions/EditActionModal.tsx`:
- Around line 215-234: Recurrence DTO currently builds firstOccurrenceDate using
form.startDate?.toISOString().split('T')[0], which uses UTC and can shift the
local date; replace that expression with a local date-only formatter (using
form.startDate's getFullYear, getMonth+1, and getDate with zero-padding) or call
a shared helper (e.g., formatLocalDate) to produce "YYYY-MM-DD" from
form.startDate so recurrenceDto.firstOccurrenceDate reflects the user's local
calendar date.

In `@frontend/src/features/actions/useActions.ts`:
- Around line 344-350: The current race check queries ActionItems for status = 0
only, which misses instances in Waiting (status = 1); update the guard in the
transaction (where tx.getOptional is called for recurrencePatternId and id) to
treat any non-completed/non-cancelled item as existing—change the SQL predicate
to exclude completed/cancelled statuses (e.g., WHERE recurrencePatternId = ? AND
id != ? AND status NOT IN (COMPLETED_STATUS, CANCELLED_STATUS) or equivalent
using numeric values) so existingActive detects statuses 0 and 1 and prevents
spawning duplicates for the same recurrencePatternId.

In `@frontend/src/features/processing/ProcessPage.tsx`:
- Around line 467-505: The submit flow currently falls back to creating a
non‑recurring action when repeatEnabled is true but repeatPattern is falsy;
change the guard in the submit handler to prevent that by validating that
repeatPattern exists when repeatEnabled is true, and if missing reject the
submit (set form error / show UI error) instead of calling
createAction.mutateAsync; specifically update the block that checks
repeatEnabled and repeatPattern (and the subsequent use of
CreateRecurrencePattern and createRecurrence.mutateAsync) to throw or return
early with a user-facing error when repeatEnabled && !repeatPattern, leaving the
normal createAction.mutateAsync path only for when repeatEnabled is false.

In `@frontend/src/features/recurrence/RecurrenceEditor.tsx`:
- Around line 35-44: RecurrenceEditor currently only uses the incoming value
prop for initial useState calls (type, frequency, fixedInterval, daysOfWeek,
dayOfMonth, weekOfMonth, intervalValue, intervalUnit), so when the parent
changes value the internal form is stale; add a useEffect in RecurrenceEditor
that watches value (and/or value?.<fields>) and updates each of those state
setters (setType, setFrequency, setFixedInterval, setDaysOfWeek, setDayOfMonth,
setWeekOfMonth, setIntervalValue, setIntervalUnit) to mirror the new prop so the
editor stays in sync when the parent changes the edited item.

In `@frontend/src/features/recurrence/RecurrenceParser.ts`:
- Around line 264-358: The relative branch in formatRecurrence can output
"undefined ..." when pattern.intervalValue is missing; update formatRecurrence
to use a safe fallback (e.g. const value = pattern.intervalValue ?? 1) and use
that value for both the numeric part and plural calculation instead of directly
referencing pattern.intervalValue, so the returned string from formatRecurrence
(for RecurrenceType.Relative) always shows a valid number and correct
pluralization.

In `@frontend/src/features/recurrence/useRecurrence.ts`:
- Around line 128-181: The two separate inserts in useCreateRecurrencePattern
(mutate) must be executed inside a single atomic db.writeTransaction so
RecurrencePatterns and ActionItems are committed together; replace the two await
db.execute(...) calls with await db.writeTransaction(async (tx) => { await
tx.execute(...) for the RecurrencePatterns INSERT and await tx.execute(...) for
the ActionItems INSERT }) using the existing patternId and actionId variables,
and ensure any caught errors are rethrown after setIsPending is cleared so
callers receive the failure (i.e., keep the finally that calls
setIsPending(false) but do not swallow exceptions).

In `@frontend/src/lib/electron.ts`:
- Around line 53-95: The exported Electron helper functions assume
window.electronAPI.auth always exists; guard the auth surface and its methods
with optional chaining and safe defaults so calls won't throw when preload lacks
auth. Update startElectronOAuth, getElectronToken, getElectronUserId,
isElectronAuthenticated, and electronSignOut to call
window.electronAPI?.auth?.<method>() and return sensible fallbacks (undefined or
null for token/userId, false for isElectronAuthenticated, and a no-op/void for
electronSignOut) so older preloads/tests that omit auth fail safely.

In `@frontend/src/main.tsx`:
- Line 6: SignInPage and ElectronAuthPage call Clerk hooks (useSignIn, useAuth,
useUser) while being rendered in Electron without a ClerkProvider; add an
isElectron() guard in each component (SignInPage and ElectronAuthPage) to
short-circuit rendering and return a simple fallback UI when isElectron() is
true so the Clerk hooks never run, or alternatively add a router-level guard
that checks isElectron() and prevents those public auth routes from being
mounted in Electron; update the components/routes to use the isElectron() check
before any Clerk hook usage to avoid runtime errors.

In `@src/OpenGTD/Features/Inbox/InboxService.cs`:
- Around line 111-112: The code in InboxService currently preserves
client-provided IDs (Id = input.Id != Guid.Empty ? input.Id : Guid.NewGuid())
but doesn’t guard against conflicts; add a uniqueness check and a safe save
path: before calling SaveChangesAsync use the DbContext (e.g.,
_db.Inboxes.AnyAsync(i => i.Id == input.Id)) when input.Id != Guid.Empty and
return a controlled conflict/validation error if it exists, and also wrap
SaveChangesAsync in a try/catch for DbUpdateException to translate DB constraint
failures into the same conflict response; keep the Guid.Empty fallback behavior
intact.

In `@src/OpenGTD/Features/Recurrence/RecurrencePatternValidator.cs`:
- Around line 20-22: The RuleFor(x => x.FixedInterval).GreaterThan(0) validation
is applied unconditionally but should only run for fixed recurrences; update the
RecurrencePatternValidator to move that rule into the When(x => x.Type ==
RecurrenceType.Fixed, ...) block (and do the same for the duplicate rule around
lines 94-95) so FixedInterval is validated only when Type ==
RecurrenceType.Fixed, keeping other relative-type rules
(IntervalValue/IntervalUnit) unaffected.
🧹 Nitpick comments (14)
frontend/src/features/inbox/useInbox.ts (1)

109-116: Same null handling inconsistency; consider extracting shared mapping logic.

This block has the same type casting issue as lines 49-56. Additionally, the mapping logic between useInboxItems() and useInboxItem() is duplicated.

Extract a shared mapper function
function mapRowToInboxItem(row: InboxItemRow): InboxItem {
  return {
    id: row.id,
    title: row.title,
    description: row.description ?? undefined,
    type: (row.type ?? InboxItemType.Note) as InboxItem['type'],
    capturedAt: row.capturedAt ?? row.createdAt ?? new Date().toISOString(),
    sourcePath: row.sourcePath ?? undefined,
    attachmentCount: row.attachmentCount ?? 0,
    createdAt: row.createdAt ?? new Date().toISOString(),
    suggestedContextId: row.suggestedContextId ?? undefined,
    suggestedProjectId: row.suggestedProjectId ?? undefined,
    pendingProjectName: row.pendingProjectName ?? undefined,
    suggestedPriority: (row.suggestedPriority ?? undefined) as InboxItem['suggestedPriority'],
    suggestedTimeMinutes: row.suggestedTimeMinutes ?? undefined,
    suggestedDueDate: row.suggestedDueDate ?? undefined,
    suggestedDestination: (row.suggestedDestination ?? undefined) as InboxItem['suggestedDestination'],
  }
}

Then use it in both hooks:

// In useInboxItems:
const items: InboxItem[] = data.map(mapRowToInboxItem)

// In useInboxItem:
const item: InboxItem | null = data[0] ? mapRowToInboxItem(data[0]) : null
frontend/src/api/swagger.json (3)

1836-1854: Add tags + align PowerSync token operationId for codegen consistency.

Nice to see an explicit PowerSyncTokenResponse schema. To keep Swagger UI grouping and NSwag-generated method names consistent, consider tagging this endpoint and aligning the operationId with the existing POST naming pattern.

🧭 Suggested spec tweak
"/api/auth/powersync-token": {
  "post": {
+    "tags": [
+      "Auth"
+    ],
-    "operationId": "GetPowerSyncToken",
+    "operationId": "PostApiAuthPowerSyncToken",
     "responses": {

Also applies to: 3512-3518


1973-1978: Mark recurrencePatternId as readOnly if server-owned.

The description implies this is system-assigned. Marking it as readOnly prevents accidental client writes in generated SDKs.

✅ Suggested spec tweak
"recurrencePatternId": {
  "type": "string",
  "description": "The recurrence pattern that created this task (if any).",
  "format": "guid",
  "nullable": true,
+ "readOnly": true
}

2670-3293: Tighten recurrence DTO constraints + confirm format: time support.

RecurrencePattern defines min/max bounds (e.g., dayOfMonth 1–31, weekOfMonth 1–5, monthOfYear 1–12, intervalValue 1–365, maxOccurrences 1–1000, templateEstimatedMinutes 1–10080) and implies fixedInterval >= 1, but the Create/Update/Sync DTOs don’t mirror these constraints or required fields (type/templateTitle/templatePriority). That can make generated clients accept invalid/incomplete payloads. Also, format: time isn’t standard in OAS 3.0—please verify NSwag/client support or consider a pattern/string format.

♻️ Suggested spec alignment (example)
"RecurrencePattern": {
  ...
  "fixedInterval": {
    "type": "integer",
    "description": "Interval between occurrences (e.g., every 2 weeks).\nDefault is 1. Only used when Type is Fixed.",
    "format": "int32",
+   "minimum": 1.0
  },
  ...
},
"CreateRecurrencePatternDto": {
  "type": "object",
  "description": "DTO for creating or updating a recurrence pattern.",
  "additionalProperties": false,
+ "required": ["type", "templateTitle", "templatePriority"],
  "properties": {
    "fixedInterval": {
      "type": "integer",
      "format": "int32",
+     "minimum": 1.0
    },
    "dayOfMonth": {
      "type": "integer",
      "format": "int32",
+     "minimum": 1.0,
+     "maximum": 31.0,
      "nullable": true
    }

Apply the same min/max constraints to weekOfMonth, monthOfYear, intervalValue, maxOccurrences, and templateEstimatedMinutes, and mirror them in UpdateRecurrencePatternDto and CreateRecurrencePatternFromSyncDto.

frontend/src/styles/_pages.css (1)

69-92: Add a focus-visible style for section links (keyboard affordance).
Optional but helps keyboard users discover the link state in the new header UI.

♿️ Suggested focus-visible styling
 .theme-ink-refined .ir-section-link:hover {
     color: var(--ir-accent);
 }
+
+.theme-ink-refined .ir-section-link:focus-visible {
+    color: var(--ir-accent);
+    outline: 2px solid var(--ir-accent);
+    outline-offset: 2px;
+}
frontend/src/features/actions/actionUtils.ts (1)

25-35: Guard invalid deadlines to keep sorting deterministic.
If a malformed deadline sneaks in, Date parsing yields NaN, which can make the comparator return NaN. Consider treating invalid dates the same as missing dates.

🛠️ Proposed hardening
 export function sortByImportance<T extends { priority: number; deadline?: string | null }>(actions: T[]): T[] {
   return [...actions].sort((a, b) => {
     if (b.priority !== a.priority) return b.priority - a.priority
-    const aDeadline = a.deadline ? new Date(a.deadline).getTime() : Infinity
-    const bDeadline = b.deadline ? new Date(b.deadline).getTime() : Infinity
+    const aTime = a.deadline ? Date.parse(a.deadline) : NaN
+    const bTime = b.deadline ? Date.parse(b.deadline) : NaN
+    const aDeadline = Number.isFinite(aTime) ? aTime : Infinity
+    const bDeadline = Number.isFinite(bTime) ? bTime : Infinity
     return aDeadline - bDeadline
   })
 }
src/OpenGTD/Features/Recurrence/RecurrencePatternValidator.cs (1)

82-143: Consider extracting shared validation rules to reduce duplication.

The UpdateRecurrencePatternDtoValidator duplicates all validation logic from CreateRecurrencePatternDtoValidator. If both DTOs share a common base class or interface, you can extract the shared rules into a helper method or use FluentValidation's Include() method to compose validators.

Example approach using a shared rule method
public class CreateRecurrencePatternDtoValidator : AbstractValidator<CreateRecurrencePatternDto>
{
    public CreateRecurrencePatternDtoValidator()
    {
        ApplyCommonRules(this);
    }

    internal static void ApplyCommonRules<T>(AbstractValidator<T> validator)
        where T : IRecurrencePatternDto  // Assuming a shared interface
    {
        validator.RuleFor(x => x.TemplateTitle)
            .NotEmpty().WithMessage("Title is required")
            .MaximumLength(500).WithMessage("Title cannot exceed 500 characters");
        // ... other shared rules
    }
}

public class UpdateRecurrencePatternDtoValidator : AbstractValidator<UpdateRecurrencePatternDto>
{
    public UpdateRecurrencePatternDtoValidator()
    {
        CreateRecurrencePatternDtoValidator.ApplyCommonRules(this);
    }
}
tests/OpenGTD.Tests/Helpers/TestDataBuilder.cs (1)

262-283: Align helper defaults with Fixed vs Relative patterns

CreateRecurrencePattern always sets fixed-schedule fields even when type is Relative, and there’s no way to set IntervalValue / IntervalUnit. That can yield inconsistent test data for relative patterns. Consider conditional defaults and optional relative params.

♻️ Suggested refactor
 public static RecurrencePattern CreateRecurrencePattern(
     string templateTitle = "Test Recurring Task",
     RecurrenceType type = RecurrenceType.Fixed,
-    RecurrenceFrequency frequency = RecurrenceFrequency.Weekly,
+    RecurrenceFrequency? frequency = RecurrenceFrequency.Weekly,
+    int fixedInterval = 1,
+    DaysOfWeek? daysOfWeek = DaysOfWeek.Monday,
+    int? intervalValue = null,
+    IntervalUnit? intervalUnit = null,
     bool isActive = true)
 {
-    return new RecurrencePattern
-    {
-        TemplateTitle = templateTitle,
-        Type = type,
-        Frequency = frequency,
-        FixedInterval = 1,
-        DaysOfWeek = DaysOfWeek.Monday,
-        IsActive = isActive,
-        TemplatePriority = Priority.Normal,
-        CreatedAt = DateTime.UtcNow
-    };
+    var pattern = new RecurrencePattern
+    {
+        TemplateTitle = templateTitle,
+        Type = type,
+        IsActive = isActive,
+        TemplatePriority = Priority.Normal,
+        CreatedAt = DateTime.UtcNow
+    };
+
+    if (type == RecurrenceType.Fixed)
+    {
+        pattern.Frequency = frequency;
+        pattern.FixedInterval = fixedInterval;
+        pattern.DaysOfWeek = frequency == RecurrenceFrequency.Weekly ? daysOfWeek : null;
+    }
+    else
+    {
+        pattern.IntervalValue = intervalValue ?? 1;
+        pattern.IntervalUnit = intervalUnit ?? IntervalUnit.Days;
+    }
+
+    return pattern;
 }
frontend/src/features/recurrence/RecurrenceField.tsx (1)

44-49: Clear the focus timeout to avoid stray callbacks.

If enabled toggles quickly or the component unmounts, the pending timer can still fire. A small cleanup keeps this safe and more idiomatic.

♻️ Suggested tweak
-    useEffect(() => {
-      if (enabled) {
-        setTimeout(() => inputRef.current?.focus(), 0)
-      }
-    }, [enabled])
+    useEffect(() => {
+      if (!enabled) return
+      const timer = window.setTimeout(() => inputRef.current?.focus(), 0)
+      return () => window.clearTimeout(timer)
+    }, [enabled])
src/OpenGTD/Features/Recurrence/RecurrenceEndpoints.cs (1)

16-18: Verify recurrence routes inherit authorization.

If the parent group doesn’t already enforce auth, these endpoints may be exposed. Please confirm the group inherits authorization, or add it here.

🔐 Optional hardening (if not already enforced)
-        var recurrence = group.MapGroup("/recurrence")
-            .WithTags("Recurrence");
+        var recurrence = group.MapGroup("/recurrence")
+            .WithTags("Recurrence")
+            .RequireAuthorization();
frontend/src/features/home/HomePage.tsx (1)

22-37: Avoid dual sources of truth for the edit modal.

showEditModal duplicates editingActionId; deriving visibility from the id keeps state consistent and clears stale IDs on close.

♻️ Suggested refactor
-  const [showEditModal, setShowEditModal] = useState(false)
   const [editingActionId, setEditingActionId] = useState<string | null>(null)
@@
-  const handleEdit = (id: string) => {
-    setEditingActionId(id)
-    setShowEditModal(true)
-  }
+  const handleEdit = (id: string) => {
+    setEditingActionId(id)
+  }
@@
-      {showEditModal && (
+      {editingActionId && (
         <EditActionModal
-          show={showEditModal}
-          onClose={() => setShowEditModal(false)}
+          show
+          onClose={() => setEditingActionId(null)}
           actionId={editingActionId}
           initialAction={editingActionId ? nextActions.find((a) => a.id === editingActionId) : null}
           defaultIsNextAction={true}
         />
       )}

Also applies to: 124-131

frontend/src/types/schemas.ts (2)

197-199: Consider adding comments for all enum schemas.

RecurrenceTypeSchema has a helpful comment. Adding similar comments for RecurrenceFrequencySchema (Daily=0, Weekly=1, Monthly=2, Yearly=3?) and IntervalUnitSchema (Days=0, Weeks=1, Months=2?) would improve maintainability.


254-254: UpdateRecurrencePatternSchema is inconsistent with other update schemas.

Other update schemas like UpdateActionItemSchema and UpdateProjectSchema use .partial().extend({ id: z.string() }). This schema only uses .omit() without adding id or calling .partial(), meaning all fields from CreateRecurrencePatternSchema remain required.

Consider aligning with the existing pattern:

-export const UpdateRecurrencePatternSchema = CreateRecurrencePatternSchema.omit({ firstOccurrenceDate: true })
+export const UpdateRecurrencePatternSchema = CreateRecurrencePatternSchema.omit({ firstOccurrenceDate: true }).partial().extend({
+  id: z.string(),
+})
frontend/src/features/recurrence/useRecurrence.ts (1)

186-233: Update does not set modifiedAt timestamp.

The UPDATE statement doesn't include modifiedAt = ? with the current timestamp. While the backend may set this on sync, the local SQLite database won't reflect when the pattern was last modified, which could affect local UI display or conflict resolution.

Suggested fix
 const mutate = async (id: string, dto: UpdateRecurrencePattern) => {
   setIsPending(true)
   try {
+    const now = new Date().toISOString()
     await db.execute(
       `UPDATE RecurrencePatterns SET
         type = ?,
         ...
-        templateIsNextAction = ?
+        templateIsNextAction = ?,
+        modifiedAt = ?
       WHERE id = ?`,
       [
         dto.type, dto.frequency ?? null, ...
-        dto.templateIsNextAction ? 1 : 0, id
+        dto.templateIsNextAction ? 1 : 0, now, id
       ]
     )

trevwilson and others added 2 commits January 20, 2026 23:19
- Add useMakeActionRecurring hook to create pattern and link existing action
- Add useRemoveActionRecurrence hook to unlink action and deactivate pattern
- EditActionModal now handles all 4 edit scenarios:
  - Update existing recurrence pattern
  - Remove recurrence from recurring action
  - Add recurrence to non-recurring action
  - No recurrence change
- Fix ir-recurrence-trigger background to use var(--ir-surface) for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Security improvements:
- Add OAuth state validation (CSRF protection) in electron auth flow
- Replace hardcoded encryption key with OS keychain via safeStorage
- Add protocol validation for callback URLs (http/https only)
- Guard Clerk hooks with isElectron() checks to prevent runtime errors
- Guard electron auth API methods with optional chaining

Bug fixes:
- Use local date formatter instead of UTC for recurrence dates
- Fix race check to include Waiting status in duplicate prevention
- Validate repeatPattern exists when repeatEnabled is true
- Sync RecurrenceEditor state with prop changes via useEffect
- Add safe fallback for intervalValue in formatRecurrence
- Wrap recurrence pattern inserts in writeTransaction for atomicity
- Add uniqueness check for client-provided inbox item IDs
- Make FixedInterval validation conditional on recurrence type

Config improvements:
- Make postgres wait loop use service name and add 60s timeout
- Make electron build output dir configurable via ELECTRON_OUTPUT_DIR
- Add language identifier to SKILL.md fenced code block

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
.vscode/tasks.json (1)

4-15: Good progress on timeout and service-based exec; Windows portability still missing.

The changes address two of the three concerns from the previous review:

  • ✓ Uses service name (postgres) instead of hard-coded container name
  • ✓ Adds 60-second timeout to prevent infinite hangs

However, the command still uses POSIX-only constructs (until, sleep, $((count+1))) that will fail on Windows (PowerShell/cmd). Consider adding a windows override as previously suggested:

🔧 Proposed fix to add Windows support
-            "command": "docker compose up -d postgres && echo 'Waiting for PostgreSQL...' && timeout=60 && count=0 && until docker compose exec -T postgres pg_isready -U gtdapp -d gtdapp > /dev/null 2>&1; do sleep 1; count=$((count+1)); if [ $count -ge $timeout ]; then echo 'Timeout waiting for PostgreSQL'; exit 1; fi; done && echo 'PostgreSQL is ready'",
+            "command": "bash -c \"docker compose up -d postgres && echo 'Waiting for PostgreSQL...' && timeout=60 && count=0 && until docker compose exec -T postgres pg_isready -U gtdapp -d gtdapp > /dev/null 2>&1; do sleep 1; count=$((count+1)); if [ $count -ge $timeout ]; then echo 'Timeout waiting for PostgreSQL'; exit 1; fi; done && echo 'PostgreSQL is ready'\"",
+            "windows": {
+                "command": "powershell -NoProfile -Command \"docker compose up -d postgres; Write-Host 'Waiting for PostgreSQL...'; $count=0; while ($count -lt 60 -and -not (docker compose exec -T postgres pg_isready -U gtdapp -d gtdapp 2>$null)) { Start-Sleep -Seconds 1; $count++ }; if ($count -ge 60) { Write-Host 'Timeout waiting for PostgreSQL'; exit 1 }; Write-Host 'PostgreSQL is ready'\""
+            },
frontend/electron/auth.ts (1)

116-130: Escape the error message to prevent potential XSS.

The error parameter from the URL is interpolated directly into HTML on line 122. While this is a localhost callback, a malicious actor who can trigger a redirect to this callback could inject script content.

🔒 Suggested fix
         if (error) {
           res.writeHead(200, { 'Content-Type': 'text/html' })
+          const safeError = error.replace(/[&<>"']/g, (c) => ({
+            '&': '&amp;',
+            '<': '&lt;',
+            '>': '&gt;',
+            '"': '&quot;',
+            "'": '&#39;'
+          }[c] || c))
           res.end(`
             <html>
               <body style="font-family: system-ui; padding: 40px; text-align: center;">
                 <h1>Authentication Failed</h1>
-                <p>${error}</p>
+                <p>${safeError}</p>
                 <p>You can close this window.</p>
               </body>
             </html>
           `)
🤖 Fix all issues with AI agents
In `@frontend/src/components/auth/ElectronAuthPage.tsx`:
- Around line 19-38: The early return for isElectron() is placed before React
hooks are called in ElectronAuthPage, violating the Rules of Hooks; fix by
ensuring hooks run unconditionally — either move all hook calls (useAuth,
useUser, useSearchParams, useState) to the top of ElectronAuthPage before the
isElectron() guard and then conditionally render the error UI, or split into two
components: keep the isElectron() check in ElectronAuthPage and render a new
ElectronAuthPageImpl that contains the hooks and the existing implementation.

In `@frontend/src/components/auth/SignInPage.tsx`:
- Around line 24-40: The conditional early return in SignInPage causes
useAuth/useSignIn/useSignUp hooks to be called conditionally; move the hook
calls (useAuth, useSignIn, useSignUp) to the top of the SignInPage function
before the isElectron() guard so hooks are always invoked in the same order,
then keep the Electron fallback return after those hooks and render the normal
sign-in UI when not Electron.

In `@frontend/src/features/actions/EditActionModal.tsx`:
- Around line 287-290: In EditActionModal.tsx, verify whether the API expects
date-only strings or full ISO timestamps for non-recurring actions; if
date-only, replace the toISOString() calls for startDate, dueDate, and deadline
in the non-recurring payload with the existing formatLocalDate(form.startDate),
formatLocalDate(form.dueDate), and formatLocalDate(form.deadline); if the
backend requires timestamps, leave them as toISOString() but add a comment
clarifying the choice. Ensure you update the payload construction inside the
submit/serialize path in the EditActionModal component where
startDate/dueDate/deadline are set.

In `@frontend/src/features/recurrence/RecurrenceEditor.tsx`:
- Around line 45-55: The effect syncing prop "value" to local state (useEffect)
only updates when value fields are defined, which leaves stale state when
switching types; modify the effect in RecurrenceEditor to explicitly reset
fields to their default/empty values when the incoming value's properties are
undefined (e.g., call setFrequency(undefined or ''),
setFixedInterval(undefined), setDaysOfWeek([]), setDayOfMonth(undefined),
setWeekOfMonth(undefined), setIntervalValue(undefined),
setIntervalUnit(undefined), and setType(value?.type ?? defaultType)) so every
relevant setter (setType, setFrequency, setFixedInterval, setDaysOfWeek,
setDayOfMonth, setWeekOfMonth, setIntervalValue, setIntervalUnit) is invoked
with either the incoming value or a cleared default to avoid retaining stale
values.

In `@frontend/src/features/recurrence/useRecurrence.ts`:
- Around line 333-357: The two DB updates in useRemoveActionRecurrence (mutate)
must be executed atomically: open a transaction on the usePowerSync DB, perform
both db.execute calls (the UPDATE ActionItems ... and UPDATE RecurrencePatterns
...) inside that transaction, then COMMIT; on any error ROLLBACK and rethrow so
state stays consistent; keep setIsPending(false) in the finally block and ensure
you use the DB's transaction API (or explicit BEGIN/COMMIT/ROLLBACK) rather than
separate executes so both updates succeed or neither do.
- Around line 362-382: The delete logic in useDeleteRecurrencePattern is not
atomic—clear FK references with db.execute and the DELETE must be wrapped in a
transaction so both succeed or both roll back; update mutate (in
useDeleteRecurrencePattern) to begin a transaction, run the UPDATE and DELETE
inside it (using the existing db transaction API or explicit
BEGIN/COMMIT/ROLLBACK via db.execute), commit on success, rollback and rethrow
on error, and ensure setIsPending(false) still runs in finally.
- Around line 285-328: The two DB operations in useMakeActionRecurring (the
INSERT into RecurrencePatterns and the UPDATE of ActionItems using patternId)
must be executed inside a single transaction so they either both succeed or both
roll back; modify the mutate function to begin a transaction before the first
db.execute, perform the INSERT and UPDATE, then COMMIT on success and ROLLBACK
on any error (catch, rollback, rethrow) so you don't leave an orphan pattern;
use the same db API (e.g., db.execute('BEGIN'), 'COMMIT', 'ROLLBACK' or the
db.transaction helper if available) and keep the existing finally block to
setIsPending(false).
🧹 Nitpick comments (6)
src/OpenGTD/Features/Recurrence/RecurrencePatternValidator.cs (1)

81-141: Consider extracting shared validation rules to reduce duplication.

Both CreateRecurrencePatternDtoValidator and UpdateRecurrencePatternDtoValidator have identical rule sets. FluentValidation's Include() method can share rules between validators.

♻️ Suggested refactor using Include()
// Create a shared rules class
public class RecurrencePatternRules : AbstractValidator<IRecurrencePatternDto>
{
    public RecurrencePatternRules()
    {
        RuleFor(x => x.TemplateTitle)
            .NotEmpty().WithMessage("Title is required")
            .MaximumLength(500).WithMessage("Title cannot exceed 500 characters");

        // ... all shared rules
    }
}

// Then in each validator:
public class CreateRecurrencePatternDtoValidator : AbstractValidator<CreateRecurrencePatternDto>
{
    public CreateRecurrencePatternDtoValidator()
    {
        Include(new RecurrencePatternRules());
        // Add any Create-specific rules here
    }
}

Alternatively, define a common interface IRecurrencePatternDto that both DTOs implement.

tests/OpenGTD.Tests/Features/Recurrence/RecurrenceServiceTests.cs (1)

194-218: Consider adding a verification that UpdateFromSyncAsync logs the creation.

The UpdateFromSyncAsync_CreatesWhenMissing test verifies the entity is created, but per the service docstring, it also logs when creating from a missing pattern. Consider verifying the logger was called to ensure the fallback path is fully covered.

💡 Optional: Verify logging behavior
// After asserting the entity was created, verify the log call:
_loggerMock.Verify(
    x => x.Log(
        LogLevel.Information,
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("not found") || v.ToString()!.Contains("Creating")),
        null,
        It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
    Times.Once);
frontend/src/features/recurrence/RecurrenceParser.ts (1)

321-330: getDayName only returns the first matching day for multi-day patterns.

When daysOfWeek has multiple days set (e.g., Monday | Wednesday), getDayName returns only the first match due to the early-return pattern. This may produce incomplete displays like "every Monday" when the user selected multiple days.

Consider handling multi-day patterns for more accurate display:

💡 Multi-day formatting suggestion
function getDayName(day: DaysOfWeek): string {
  const days: string[] = []
  if (day & DaysOfWeek.Sunday) days.push('Sunday')
  if (day & DaysOfWeek.Monday) days.push('Monday')
  if (day & DaysOfWeek.Tuesday) days.push('Tuesday')
  if (day & DaysOfWeek.Wednesday) days.push('Wednesday')
  if (day & DaysOfWeek.Thursday) days.push('Thursday')
  if (day & DaysOfWeek.Friday) days.push('Friday')
  if (day & DaysOfWeek.Saturday) days.push('Saturday')
  return days.join(', ') || ''
}
frontend/src/features/recurrence/RecurrenceEditor.tsx (1)

74-76: Effect emits changes on every dependency update, which may cause unnecessary callbacks.

The useEffect at lines 74-76 calls emitChange() whenever any form state changes. This is correct for notifying the parent, but if the parent's onChange updates value, and the sync effect (lines 45-55) then updates local state, this could cause multiple emissions per user interaction.

This is likely acceptable given the conditional sync logic, but consider debouncing or comparing with previous emitted value if performance issues arise.

frontend/src/features/actions/useActions.ts (1)

388-403: Hardcoded priority default (2) should use the enum constant.

Line 393 uses pattern.templatePriority ?? 2 as a fallback. While 2 likely corresponds to Priority.Normal, using the enum constant would be more maintainable and self-documenting.

💡 Use enum constant
-            pattern.templatePriority ?? 2,
+            pattern.templatePriority ?? Priority.Normal,

Note: This requires importing Priority if not already available in scope.

frontend/src/features/processing/ProcessPage.tsx (1)

475-521: Inconsistent date format between recurrence and regular action paths.

Recurrence uses local YYYY-MM-DD format via formatLocalDate (line 502), while regular actions use toISOString() which outputs UTC (lines 516-518). This inconsistency could cause off-by-one-day errors depending on the user's timezone and how the backend interprets these dates.

Consider unifying the date formatting approach. If the backend expects local dates for scheduling purposes, the regular action path should also use formatLocalDate.

♻️ Suggested unification
           } else {
             // Create a regular (non-recurring) action
             const dto: CreateActionItem = {
               title: actionForm.title,
               description: actionForm.description || undefined,
               contextId: actionForm.contextId,
               projectId: actionForm.projectId,
               priority: actionForm.priority,
               energyLevel: actionForm.energyLevel,
               estimatedMinutes: actionForm.estimatedMinutes,
               isNextAction: actionForm.isNextAction,
-              startDate: actionForm.startDate?.toISOString(),
-              dueDate: actionForm.dueDate?.toISOString(),
-              deadline: actionForm.deadline?.toISOString(),
+              startDate: formatLocalDate(actionForm.startDate),
+              dueDate: formatLocalDate(actionForm.dueDate),
+              deadline: formatLocalDate(actionForm.deadline),
             }
             await createAction.mutateAsync(dto)

Please verify whether the backend expects ISO timestamps or local date strings for CreateActionItem date fields.

trevwilson and others added 3 commits January 20, 2026 23:53
- ElectronAuthPage: Move hooks before isElectron() check to comply with Rules of Hooks
- SignInPage: Move hooks before isElectron() check to comply with Rules of Hooks
- RecurrenceEditor: Reset all fields to defaults in useEffect to avoid stale state
- useRecurrence: Wrap useMakeActionRecurring, useRemoveActionRecurrence, and
  useDeleteRecurrencePattern in writeTransaction for atomicity
- EditActionModal: Use formatLocalDate consistently for all date fields to avoid
  UTC timezone shifts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix getDayName to handle multi-day patterns (e.g., "Monday, Wednesday")
- Replace magic number 2 with Priority.Normal enum constant
- Unify date formatting to use local dates, preventing timezone shifts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/OpenGTD/Features/Inbox/InboxService.cs`:
- Around line 165-171: The InboxItem construction uses
parseResult.Title/RawTitle which is only defined when clientParsed is false;
update the initializer to use finalTitle for Title and compute RawTitle by
comparing input.Title to finalTitle (store input.Title only if different) so the
code compiles for both branches—modify the InboxItem initializer in the method
that defines parseResult, clientParsed and finalTitle to set Title = finalTitle
and RawTitle = input.Title != finalTitle ? input.Title : null while keeping Id,
Description, etc. unchanged.
♻️ Duplicate comments (1)
src/OpenGTD/Features/Inbox/InboxService.cs (1)

153-188: Handle TOCTOU on duplicate IDs during save.

The pre-check reduces collisions, but a concurrent insert can still trigger a DbUpdateException on SaveChangesAsync. Consider catching it and translating to the same conflict response to avoid leaking unhandled DB errors.

parseResult is only defined in the server-parsing branch, but the
InboxItem constructor was referencing it unconditionally. Use finalTitle
which is set in both client-parsed and server-parsed branches.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@trevwilson trevwilson merged commit 29bf319 into master Jan 21, 2026
@trevwilson trevwilson deleted the feature/recurring-tasks branch January 21, 2026 05:05
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@frontend/src/components/auth/ElectronAuthPage.tsx`:
- Around line 29-40: The useEffect hooks in ElectronAuthPage are being invoked
conditionally due to the early return guard (isElectron()), violating Rules of
Hooks; fix this by splitting the component into a simple wrapper
(ElectronAuthPage) that checks isElectron() and returns the error UI early, and
a separate implementation component (e.g., ElectronAuthPageImpl) that contains
all hooks (useEffect, useAuth, useUser, useSearchParams, useState) and the rest
of the JSX; have the wrapper render <ElectronAuthPageImpl /> when not in
Electron so hooks are always called unconditionally inside the implementation
component.
- Around line 90-94: In ElectronAuthPage's effect that checks auth state, don't
setStatus('signing-in') when a token exists but user?.id is not yet loaded
because that triggers an unwanted redirect; instead, when token is present and
user?.id is falsy simply return (or set a neutral status like 'loading-user')
and wait for user to populate so the effect re-runs and handles the redirect
only once user data is actually available; update the logic around the setStatus
call that currently runs when !user?.id to only run when there is no token (or
explicitly signing out).
🧹 Nitpick comments (2)
frontend/src/features/recurrence/RecurrenceParser.ts (1)

168-193: Consider adding support for "every N years" pattern.

The interval pattern handles "every N days/weeks/months" but not "every N years". While "yearly" is supported, users might expect "every 2 years" to work.

♻️ Optional enhancement
-  const intervalMatch = text.match(/^every\s+(\d+)\s+(days?|weeks?|months?)$/i)
+  const intervalMatch = text.match(/^every\s+(\d+)\s+(days?|weeks?|months?|years?)$/i)
   if (intervalMatch) {
     const interval = parseInt(intervalMatch[1], 10)
     const unit = intervalMatch[2].toLowerCase()

     if (unit.startsWith('day')) {
       return {
         type: RecurrenceType.Fixed,
         frequency: RecurrenceFrequency.Daily,
         fixedInterval: interval,
       }
     } else if (unit.startsWith('week')) {
       return {
         type: RecurrenceType.Fixed,
         frequency: RecurrenceFrequency.Weekly,
         fixedInterval: interval,
       }
     } else if (unit.startsWith('month')) {
       return {
         type: RecurrenceType.Fixed,
         frequency: RecurrenceFrequency.Monthly,
         fixedInterval: interval,
       }
+    } else if (unit.startsWith('year')) {
+      return {
+        type: RecurrenceType.Fixed,
+        frequency: RecurrenceFrequency.Yearly,
+        fixedInterval: interval,
+      }
     }
   }
frontend/src/features/recurrence/useRecurrence.ts (1)

186-236: Consider updating modifiedAt timestamp on pattern updates.

The UPDATE statement doesn't set modifiedAt, which may affect sync behavior or audit trails. Other update operations in the codebase (e.g., in useCompleteAction) set modifiedAt = ? with the current timestamp.

♻️ Suggested fix
   const mutate = async (id: string, dto: UpdateRecurrencePattern) => {
     setIsPending(true)
     try {
+      const now = new Date().toISOString()
       await db.execute(
         `UPDATE RecurrencePatterns SET
           type = ?,
           frequency = ?,
           fixedInterval = ?,
           daysOfWeek = ?,
           dayOfMonth = ?,
           weekOfMonth = ?,
           monthOfYear = ?,
           intervalValue = ?,
           intervalUnit = ?,
           startTime = ?,
           endDate = ?,
           maxOccurrences = ?,
           templateTitle = ?,
           templateDescription = ?,
           templateContextId = ?,
           templateProjectId = ?,
           templatePriority = ?,
           templateEnergyLevel = ?,
           templateEstimatedMinutes = ?,
-          templateIsNextAction = ?
+          templateIsNextAction = ?,
+          modifiedAt = ?
         WHERE id = ?`,
         [
           dto.type, dto.frequency ?? null, dto.fixedInterval ?? 1,
           dto.daysOfWeek ?? null, dto.dayOfMonth ?? null, dto.weekOfMonth ?? null, dto.monthOfYear ?? null,
           dto.intervalValue ?? null, dto.intervalUnit ?? null, dto.startTime ?? null, dto.endDate ?? null,
           dto.maxOccurrences ?? null,
           dto.templateTitle, dto.templateDescription ?? null, dto.templateContextId ?? null, dto.templateProjectId ?? null,
           dto.templatePriority ?? 2, dto.templateEnergyLevel ?? null, dto.templateEstimatedMinutes ?? null,
-          dto.templateIsNextAction ? 1 : 0, id
+          dto.templateIsNextAction ? 1 : 0, now, id
         ]
       )

Comment on lines +29 to 40
// This page should only run in the browser (opened by Electron), not in Electron itself
if (isElectron()) {
return (
<div style={{ padding: '40px', textAlign: 'center', fontFamily: 'system-ui' }}>
<h1>Configuration Error</h1>
<p>This page should open in your browser, not the app.</p>
<p>Please close this and try again.</p>
</div>
)
}

useEffect(() => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hooks still called conditionally after early return, violating Rules of Hooks.

The early return at lines 30-38 causes the useEffect hooks at lines 40 and 116 to be called conditionally. While the previous review addressed moving useAuth, useUser, useSearchParams, and useState before the guard, the useEffect hooks remain after it. Biome correctly flags this violation.

The cleanest fix is to split into wrapper and implementation components:

Suggested fix: Split into wrapper and implementation components
+function ElectronAuthPageImpl() {
+  const { isSignedIn, isLoaded, getToken } = useAuth()
+  const { user } = useUser()
+  const [searchParams] = useSearchParams()
+  const [status, setStatus] = useState<'loading' | 'signing-in' | 'getting-token' | 'redirecting' | 'error'>('loading')
+  const [error, setError] = useState<string | null>(null)
+
+  const callbackUrl = searchParams.get('callback')
+  const oauthState = searchParams.get('state')
+
+  useEffect(() => {
+    // ... existing auth logic
+  }, [isLoaded, isSignedIn, callbackUrl, getToken, user?.id, oauthState])
+
+  useEffect(() => {
+    // ... existing sign-in redirect logic
+  }, [status, callbackUrl, oauthState])
+
+  // ... existing render logic
+}
+
 export function ElectronAuthPage() {
-  // Hooks must be called unconditionally (Rules of Hooks)
-  const { isSignedIn, isLoaded, getToken } = useAuth()
-  const { user } = useUser()
-  const [searchParams] = useSearchParams()
-  const [status, setStatus] = useState<'loading' | 'signing-in' | 'getting-token' | 'redirecting' | 'error'>('loading')
-  const [error, setError] = useState<string | null>(null)
-
-  const callbackUrl = searchParams.get('callback')
-  const oauthState = searchParams.get('state')
-
-  // This page should only run in the browser (opened by Electron), not in Electron itself
   if (isElectron()) {
     return (
       <div style={{ padding: '40px', textAlign: 'center', fontFamily: 'system-ui' }}>
         <h1>Configuration Error</h1>
         <p>This page should open in your browser, not the app.</p>
         <p>Please close this and try again.</p>
       </div>
     )
   }
-
-  useEffect(() => {
-    // ...
+  return <ElectronAuthPageImpl />
+}

Also applies to: 116-116

🧰 Tools
🪛 Biome (2.1.2)

[error] 40-40: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
In `@frontend/src/components/auth/ElectronAuthPage.tsx` around lines 29 - 40, The
useEffect hooks in ElectronAuthPage are being invoked conditionally due to the
early return guard (isElectron()), violating Rules of Hooks; fix this by
splitting the component into a simple wrapper (ElectronAuthPage) that checks
isElectron() and returns the error UI early, and a separate implementation
component (e.g., ElectronAuthPageImpl) that contains all hooks (useEffect,
useAuth, useUser, useSearchParams, useState) and the rest of the JSX; have the
wrapper render <ElectronAuthPageImpl /> when not in Electron so hooks are always
called unconditionally inside the implementation component.

Comment on lines +90 to +94
// Wait for user data to be loaded before redirecting
if (!user?.id) {
setStatus('signing-in')
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Setting status to 'signing-in' when user data isn't loaded may cause unnecessary redirect.

When the token is successfully obtained but user?.id is not yet available, setting status to 'signing-in' triggers a redirect to the sign-in page—even though the user is already signed in. Since user?.id is in the effect dependencies, the effect will re-run when user data loads, but by then the redirect may have already started.

Consider waiting for user data instead:

Suggested fix
         // Wait for user data to be loaded before redirecting
         if (!user?.id) {
-          setStatus('signing-in')
+          // User data still loading, effect will re-run when user?.id changes
           return
         }
🤖 Prompt for AI Agents
In `@frontend/src/components/auth/ElectronAuthPage.tsx` around lines 90 - 94, In
ElectronAuthPage's effect that checks auth state, don't setStatus('signing-in')
when a token exists but user?.id is not yet loaded because that triggers an
unwanted redirect; instead, when token is present and user?.id is falsy simply
return (or set a neutral status like 'loading-user') and wait for user to
populate so the effect re-runs and handles the redirect only once user data is
actually available; update the logic around the setStatus call that currently
runs when !user?.id to only run when there is no token (or explicitly signing
out).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants