Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,6 @@ const eslintConfig = [
version: '19.2',
},
},
rules: {
// These two rules were newly enabled by eslint-plugin-react-hooks@7,
// which ships with eslint-config-next@16 (Next.js 16 upgrade). They flag
// ~20 pre-existing, idiomatic sites (e.g. `setMounted(true)` hydration
// guards, FocusTimer's interdependent timer state, self-referencing retry
// callbacks) — none are bugs introduced by the upgrade. Disabled here to
// keep the dependency bump free of behavior-sensitive refactors; tracked
// for a dedicated follow-up to enforce incrementally. See PR/upgrade notes.
'react-hooks/set-state-in-effect': 'off',
'react-hooks/immutability': 'off',
},
},
];

Expand Down
11 changes: 7 additions & 4 deletions src/components/archive-mission-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useCommandOpsStore } from '@/store/command-ops-store';
import {
Dialog,
Expand Down Expand Up @@ -34,13 +34,16 @@ export function ArchiveMissionDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [reportError, setReportError] = useState('');

// Reset form when dialog opens/closes
useEffect(() => {
// Reset form when the dialog closes (adjust state during render on the
// open->closed transition instead of in an effect).
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (!open) {
setAfterActionReport('');
setReportError('');
}
}, [open]);
}

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down
33 changes: 11 additions & 22 deletions src/components/archive/date-range-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
'use client';

import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
Popover,
Expand Down Expand Up @@ -107,14 +101,14 @@ export const DateRangeSelector: React.FC<DateRangeSelectorProps> = ({

const [tempRange, setTempRange] = useState<DateRange>(range);

useEffect(() => {
// Sync tempRange when the incoming range changes (render-time).
const [prevRange, setPrevRange] = useState(range);
if (range !== prevRange) {
setPrevRange(range);
setTempRange(range);
Comment on lines +104 to 108
}, [range]);
}

const openedRangeRef = useRef<DateRange | undefined>(undefined);
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
undefined
);

const [isSmallScreen, setIsSmallScreen] = useState(false);

Expand Down Expand Up @@ -197,10 +191,10 @@ export const DateRangeSelector: React.FC<DateRangeSelectorProps> = ({
setTempRange(newRange);
};

const checkPreset = useCallback((): void => {
// Derive the matching preset name from the current range (render-time).
const selectedPreset = useMemo<string | undefined>(() => {
if (!range.from) {
setSelectedPreset(undefined);
return;
return undefined;
}

for (const preset of PRESETS) {
Expand All @@ -222,18 +216,13 @@ export const DateRangeSelector: React.FC<DateRangeSelectorProps> = ({
normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() &&
normalizedRangeTo?.getTime() === normalizedPresetTo?.getTime()
) {
setSelectedPreset(preset.name);
return;
return preset.name;
}
}

setSelectedPreset(undefined);
return undefined;
}, [range]);

useEffect(() => {
checkPreset();
}, [checkPreset]);

const PresetButton = ({
preset,
label,
Expand Down
12 changes: 8 additions & 4 deletions src/components/archive/filters/quest-filters.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Search, X } from 'lucide-react';
import { useArchiveStore } from '@/store/archive-store';
import { Input } from '@/components/ui/input';
Expand Down Expand Up @@ -36,8 +36,12 @@ export const QuestFilters: React.FC = () => {
// Track applied filters to detect changes
const [appliedFilters, setAppliedFilters] = useState(questFilters);

// Sync applied filters when filters are reset externally
useEffect(() => {
// Sync applied filters when filters are reset externally. Runs during render
// on a questFilters change instead of in an effect.
const [prevQuestFilters, setPrevQuestFilters] = useState(questFilters);
if (questFilters !== prevQuestFilters) {
setPrevQuestFilters(questFilters);

// If all filters match defaults, sync the applied state
const isDefaultState =
JSON.stringify(questFilters) ===
Expand All @@ -55,7 +59,7 @@ export const QuestFilters: React.FC = () => {
) {
setAppliedFilters({ ...questFilters });
}
}, [questFilters, appliedFilters]);
}

const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuestFilters({ searchQuery: e.target.value });
Expand Down
11 changes: 7 additions & 4 deletions src/components/edit-mission-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useCommandOpsStore } from '@/store/command-ops-store';
import {
Dialog,
Expand Down Expand Up @@ -39,13 +39,16 @@ export function EditMissionDialog({
const [titleError, setTitleError] = useState('');
const [descriptionError, setDescriptionError] = useState('');

// Populate form when mission changes
useEffect(() => {
// Populate form when the mission changes (adjust state during render on the
// mission-identity change instead of in an effect).
const [prevMissionId, setPrevMissionId] = useState(mission?.id ?? null);
if ((mission?.id ?? null) !== prevMissionId) {
setPrevMissionId(mission?.id ?? null);
if (mission) {
setTitle(mission.title);
setDescription(mission.objective || '');
}
}, [mission]);
}

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down
66 changes: 40 additions & 26 deletions src/components/focus-mode/FocusTimer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Play, Pause, RotateCcw } from 'lucide-react';
import { posthogCapture } from '@/lib/posthog-utils';

Expand All @@ -12,38 +12,49 @@ interface FocusTimerProps {
export function FocusTimer({ timerType, duration }: FocusTimerProps) {
const [isRunning, setIsRunning] = useState(true);
const [timeElapsed, setTimeElapsed] = useState(0); // in seconds
const [timeRemaining, setTimeRemaining] = useState(0); // in seconds
const [timeRemaining, setTimeRemaining] = useState(() =>
timerType === 'pomodoro'
? (duration || 25) * 60
: timerType === 'countdown' && duration
? duration * 60
: 0
); // in seconds
const [pausedAt, setPausedAt] = useState<Date | null>(null);
const [totalPausedTime, setTotalPausedTime] = useState(0); // in seconds
const [timerStartTime, setTimerStartTime] = useState<Date | null>(null);
const [sessionCompleted, setSessionCompleted] = useState(false);
const [timerStartTime, setTimerStartTime] = useState<Date | null>(
() => new Date()
);
// Guard flag (never rendered) so the completion effect fires analytics once.
const sessionCompletedRef = useRef(false);

// Reset timer when mode or duration changes
useEffect(() => {
const now = new Date();
setTimerStartTime(now);
// Reset the timer when the mode or duration changes (render-time, on the
// config change; initial values are seeded in useState above for mount).
const [prevConfig, setPrevConfig] = useState({ timerType, duration });
if (prevConfig.timerType !== timerType || prevConfig.duration !== duration) {
setPrevConfig({ timerType, duration });
setTimerStartTime(new Date());
setTimeElapsed(0);
setTotalPausedTime(0);
setPausedAt(null);
setIsRunning(true);
setSessionCompleted(false);

// Set initial time remaining
if (timerType === 'pomodoro') {
const totalSeconds = (duration || 25) * 60;
setTimeRemaining(totalSeconds);
setTimeRemaining((duration || 25) * 60);
} else if (timerType === 'countdown' && duration) {
const totalSeconds = duration * 60;
setTimeRemaining(totalSeconds);
setTimeRemaining(duration * 60);
} else {
setTimeRemaining(0);
}
}

// Track focus session start
// Track focus session start (on mount and whenever the config changes).
// Also resets the completion guard for the new session.
useEffect(() => {
sessionCompletedRef.current = false;
posthogCapture('focus_session_started', {
timer_type: timerType,
duration_minutes: duration,
session_start_time: now.toISOString(),
session_start_time: new Date().toISOString(),
});
}, [timerType, duration]);

Expand Down Expand Up @@ -78,26 +89,29 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) {
return () => clearInterval(interval);
}, [timerStartTime, timerType, duration, isRunning, totalPausedTime]);

// Handle pause/resume logic
useEffect(() => {
if (!isRunning && !pausedAt) {
// Handle pause/resume logic on the isRunning transition (render-time).
const [prevIsRunning, setPrevIsRunning] = useState(isRunning);
if (isRunning !== prevIsRunning) {
setPrevIsRunning(isRunning);
if (!isRunning) {
setPausedAt(new Date());
} else if (isRunning && pausedAt) {
} else if (pausedAt) {
const pauseDuration = Math.floor(
(new Date().getTime() - pausedAt.getTime()) / 1000
);
setTotalPausedTime(prev => prev + pauseDuration);
setPausedAt(null);
}
}, [isRunning, pausedAt]);
}

// Track timer completion
// Track timer completion (analytics side effect; sessionCompletedRef guards
// against firing more than once).
useEffect(() => {
if (
(timerType === 'pomodoro' || timerType === 'countdown') &&
timeRemaining === 0 &&
timeElapsed > 0 &&
!sessionCompleted
!sessionCompletedRef.current
) {
posthogCapture('focus_session_completed', {
timer_type: timerType,
Expand All @@ -106,9 +120,9 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) {
total_paused_seconds: totalPausedTime,
completion_time: new Date().toISOString(),
});
setSessionCompleted(true);
sessionCompletedRef.current = true;
}
}, [timeRemaining, timerType, duration, timeElapsed, totalPausedTime, sessionCompleted]);
}, [timeRemaining, timerType, duration, timeElapsed, totalPausedTime]);

const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
Expand Down Expand Up @@ -155,7 +169,7 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) {
setTimeElapsed(0);
setTotalPausedTime(0);
setPausedAt(null);
setSessionCompleted(false);
sessionCompletedRef.current = false;

// Reset time remaining based on current timer type
if (timerType === 'pomodoro') {
Expand Down
47 changes: 29 additions & 18 deletions src/components/focus-mode/InterruptCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,41 @@ export function InterruptCapture({ isOpen, onClose }: InterruptCaptureProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { addQuest } = useCommandOpsStore();

useEffect(() => {
// Reset capture state when the modal opens (render-time, on the open
// transition) so the effect below only handles focus + the countdown timer.
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (isOpen) {
setTitle('');
setCountdown(15);
setIsSubmitting(false);
}
}

// Focus input after a brief delay
setTimeout(() => {
inputRef.current?.focus();
}, 100);

// Start countdown
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
onClose();
return 0;
}
return prev - 1;
});
}, 1000);
useEffect(() => {
if (!isOpen) return;

// Focus input after a brief delay
const focusTimeout = setTimeout(() => {
inputRef.current?.focus();
}, 100);

// Start countdown
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
onClose();
return 0;
}
return prev - 1;
});
}, 1000);

return () => clearInterval(timer);
}
return () => {
clearTimeout(focusTimeout);
clearInterval(timer);
};
}, [isOpen, onClose]);

const handleSubmit = useCallback(async () => {
Expand Down
Loading