@@ -84,15 +84,42 @@ def active_timer(self) -> TimeEntry | None:
8484
8585
8686class TimeEntry (models .Model ):
87- """A single time log entry against a project."""
87+ """A single time log entry against a project.
88+
89+ A timer-managed entry is worked in one or more segments. ``status`` tracks
90+ its lifecycle, ``accumulated_minutes`` holds the time from completed
91+ segments, and ``segment_started_at`` marks the start of the currently
92+ running segment (``None`` while paused). ``start_time`` is the first start,
93+ ``end_time`` the final stop, and ``duration_minutes`` the worked total
94+ (excluding paused gaps). At most one entry may be ``RUNNING`` at a time.
95+ """
96+
97+ class Status (models .TextChoices ):
98+ RUNNING = "running" , "Running"
99+ PAUSED = "paused" , "Paused"
100+ COMPLETED = "completed" , "Completed"
88101
89102 project = models .ForeignKey (Project , on_delete = models .CASCADE , related_name = "entries" )
90103 date = models .DateField (default = timezone .localdate )
91104 start_time = models .DateTimeField (null = True , blank = True )
92105 end_time = models .DateTimeField (null = True , blank = True )
93106 duration_minutes = models .PositiveIntegerField (
94107 default = 0 ,
95- help_text = "Duration in minutes. Auto-calculated from start/end if both are set." ,
108+ help_text = "Worked duration in minutes (excludes paused gaps)." ,
109+ )
110+ status = models .CharField (
111+ max_length = 10 ,
112+ choices = Status .choices ,
113+ default = Status .COMPLETED ,
114+ )
115+ accumulated_minutes = models .PositiveIntegerField (
116+ default = 0 ,
117+ help_text = "Worked minutes from completed segments (before the live one)." ,
118+ )
119+ segment_started_at = models .DateTimeField (
120+ null = True ,
121+ blank = True ,
122+ help_text = "Start of the currently running segment; null while paused." ,
96123 )
97124 description = models .TextField (blank = True )
98125 created_at = models .DateTimeField (auto_now_add = True )
@@ -105,17 +132,68 @@ def __str__(self) -> str:
105132 return f"{ self .project .name } · { self .date } · { self .duration_display } "
106133
107134 def save (self , * args , ** kwargs ):
108- # Auto-compute duration when both timestamps are set
109- if self .start_time and self .end_time :
135+ # Derive duration from the span only for simple entries that never used
136+ # pause/resume. Timer-managed entries set duration_minutes in stop().
137+ if self .start_time and self .end_time and self .accumulated_minutes == 0 :
110138 delta : timedelta = self .end_time - self .start_time
111139 self .duration_minutes = max (0 , int (delta .total_seconds () // 60 ))
112140 if not self .date :
113141 self .date = timezone .localdate (self .start_time )
114142 super ().save (* args , ** kwargs )
115143
144+ # ----- Timer lifecycle -----
145+
146+ def _segment_minutes (self , when ) -> int :
147+ """Minutes elapsed in the currently running segment, if any."""
148+ if not self .segment_started_at :
149+ return 0
150+ delta = when - self .segment_started_at
151+ return max (0 , int (delta .total_seconds () // 60 ))
152+
153+ def pause (self , when = None ) -> None :
154+ """Hold a running timer: bank the live segment, stop counting."""
155+ if self .status != self .Status .RUNNING :
156+ return
157+ when = when or timezone .now ()
158+ self .accumulated_minutes += self ._segment_minutes (when )
159+ self .segment_started_at = None
160+ self .status = self .Status .PAUSED
161+ self .save (update_fields = ["accumulated_minutes" , "segment_started_at" , "status" ])
162+
163+ def resume (self , when = None ) -> None :
164+ """Make this the active timer, holding any other running one."""
165+ when = when or timezone .now ()
166+ for other in TimeEntry .objects .filter (status = self .Status .RUNNING ).exclude (pk = self .pk ):
167+ other .pause (when = when )
168+ self .segment_started_at = when
169+ self .status = self .Status .RUNNING
170+ self .save (update_fields = ["segment_started_at" , "status" ])
171+
172+ def stop (self , when = None ) -> None :
173+ """Finalize the session into a completed entry."""
174+ when = when or timezone .now ()
175+ if self .status == self .Status .RUNNING :
176+ self .accumulated_minutes += self ._segment_minutes (when )
177+ self .segment_started_at = None
178+ self .end_time = when
179+ self .duration_minutes = self .accumulated_minutes
180+ self .status = self .Status .COMPLETED
181+ self .save ()
182+
183+ # ----- State helpers -----
184+
116185 @property
117186 def is_running (self ) -> bool :
118- return self .start_time is not None and self .end_time is None
187+ return self .status == self .Status .RUNNING
188+
189+ @property
190+ def is_paused (self ) -> bool :
191+ return self .status == self .Status .PAUSED
192+
193+ @property
194+ def is_open (self ) -> bool :
195+ """Running or paused — an unfinished session shown on the dashboard."""
196+ return self .status in (self .Status .RUNNING , self .Status .PAUSED )
119197
120198 @property
121199 def duration_display (self ) -> str :
@@ -124,10 +202,11 @@ def duration_display(self) -> str:
124202 return f"{ hours :d} h { mins :02d} m"
125203
126204 def current_duration_minutes (self ) -> int :
127- """Live duration: for running timers, returns minutes since start."""
128- if self .is_running and self .start_time :
129- delta = timezone .now () - self .start_time
130- return max (0 , int (delta .total_seconds () // 60 ))
205+ """Worked minutes so far: banked segments plus the live one if running."""
206+ if self .status == self .Status .RUNNING :
207+ return self .accumulated_minutes + self ._segment_minutes (timezone .now ())
208+ if self .status == self .Status .PAUSED :
209+ return self .accumulated_minutes
131210 return self .duration_minutes
132211
133212 def clean (self ):
0 commit comments