Skip to content

Commit 2f0f3b3

Browse files
Add pausable timers with auto-hold
Multiple timer sessions can now stay open at once; exactly one is running and the rest are held (paused), so switching between projects no longer requires stopping and re-starting. - TimeEntry gains status (running/paused/completed), accumulated_minutes, and segment_started_at; pause()/resume()/stop() track worked time across segments (excluding paused gaps). resume() and starting a new timer auto-hold any other running timer (single-active invariant). - New timer_pause / timer_resume views; timer_start resumes an existing open session instead of duplicating it. - Dashboard shows Active and "On hold" separately, with Pause/Resume/Stop controls; the live counter ticks on the current segment. - Data migration backfills existing rows (open -> running, else completed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 910e6e7 commit 2f0f3b3

7 files changed

Lines changed: 419 additions & 40 deletions

File tree

tracker/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class ProjectAdmin(admin.ModelAdmin):
2727

2828
@admin.register(TimeEntry)
2929
class TimeEntryAdmin(admin.ModelAdmin):
30-
list_display = ("project", "date", "start_time", "end_time", "duration_minutes")
31-
list_filter = ("project", "date")
30+
list_display = ("project", "date", "status", "start_time", "end_time", "duration_minutes")
31+
list_filter = ("project", "status", "date")
3232
search_fields = ("description",)
3333
date_hierarchy = "date"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Generated by Django 5.0.6 on 2026-06-01 08:49
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
6+
7+
def backfill_open_timers(apps, schema_editor):
8+
"""Pre-existing open timers (no end_time) were single-segment running timers."""
9+
TimeEntry = apps.get_model("tracker", "TimeEntry")
10+
TimeEntry.objects.filter(end_time__isnull=True).update(
11+
status="running",
12+
segment_started_at=models.F("start_time"),
13+
)
14+
15+
16+
def noop(apps, schema_editor):
17+
pass
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = [
23+
('tracker', '0001_initial'),
24+
]
25+
26+
operations = [
27+
migrations.AddField(
28+
model_name='timeentry',
29+
name='accumulated_minutes',
30+
field=models.PositiveIntegerField(default=0, help_text='Worked minutes from completed segments (before the live one).'),
31+
),
32+
migrations.AddField(
33+
model_name='timeentry',
34+
name='segment_started_at',
35+
field=models.DateTimeField(blank=True, help_text='Start of the currently running segment; null while paused.', null=True),
36+
),
37+
migrations.AddField(
38+
model_name='timeentry',
39+
name='status',
40+
field=models.CharField(choices=[('running', 'Running'), ('paused', 'Paused'), ('completed', 'Completed')], default='completed', max_length=10),
41+
),
42+
migrations.AlterField(
43+
model_name='project',
44+
name='color',
45+
field=models.CharField(default='#0d6efd', help_text='Hex color (e.g. #0d6efd) used as a visual marker.', max_length=7, validators=[django.core.validators.RegexValidator(message='Color must be a hex value like #0d6efd or #abc.', regex='^#(?:[0-9a-fA-F]{3}){1,2}$')]),
46+
),
47+
migrations.AlterField(
48+
model_name='timeentry',
49+
name='duration_minutes',
50+
field=models.PositiveIntegerField(default=0, help_text='Worked duration in minutes (excludes paused gaps).'),
51+
),
52+
migrations.RunPython(backfill_open_timers, noop),
53+
]

tracker/models.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,42 @@ def active_timer(self) -> TimeEntry | None:
8484

8585

8686
class 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):

tracker/templates/tracker/dashboard.html

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,19 @@ <h1 class="h3 mb-0">Dashboard</h1>
2222
{{ entry.project.name }}
2323
</div>
2424
<div class="text-muted">Started {{ entry.start_time|time:"H:i" }} ·
25-
<span class="js-timer" data-start="{{ entry.start_time|date:'c' }}">{{ entry.duration_display }}</span>
25+
<span class="js-timer"
26+
data-accumulated="{{ entry.accumulated_minutes }}"
27+
data-segment-start="{{ entry.segment_started_at|date:'c' }}">{{ entry.duration_display }}</span>
2628
</div>
2729
</div>
28-
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}" class="d-flex gap-2">
30+
<form method="post" action="{% url 'tracker:timer_pause' entry.id %}">
31+
{% csrf_token %}
32+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
33+
<button type="submit" class="btn btn-outline-warning">
34+
<i class="bi bi-pause-circle me-1"></i>Pause
35+
</button>
36+
</form>
37+
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}">
2938
{% csrf_token %}
3039
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
3140
<button type="submit" class="btn btn-success">
@@ -44,6 +53,48 @@ <h1 class="h3 mb-0">Dashboard</h1>
4453
{% endfor %}
4554
{% endif %}
4655

56+
{% if held %}
57+
<h2 class="h5 text-muted mb-3"><i class="bi bi-pause-circle me-1"></i>On hold</h2>
58+
<div class="row g-3 mb-4">
59+
{% for entry in held %}
60+
<div class="col-md-6 col-lg-4">
61+
<div class="card h-100 border-warning shadow-sm">
62+
<div class="card-body d-flex flex-column gap-2">
63+
<div class="fw-semibold">
64+
<span class="rounded-circle me-2" style="display:inline-block;background:{{ entry.project.color }};width:14px;height:14px;"></span>
65+
{{ entry.project.name }}
66+
</div>
67+
<div class="text-muted small">Held at {{ entry.duration_display }}</div>
68+
<div class="d-flex gap-2 mt-auto">
69+
<form method="post" action="{% url 'tracker:timer_resume' entry.id %}" class="flex-grow-1">
70+
{% csrf_token %}
71+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
72+
<button type="submit" class="btn btn-outline-primary w-100">
73+
<i class="bi bi-play-circle me-1"></i>Resume
74+
</button>
75+
</form>
76+
<form method="post" action="{% url 'tracker:timer_stop' entry.id %}">
77+
{% csrf_token %}
78+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
79+
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
80+
<i class="bi bi-stop-circle"></i>
81+
</button>
82+
</form>
83+
<form method="post" action="{% url 'tracker:timer_cancel' entry.id %}" onsubmit="return confirm('Discard this held timer?');">
84+
{% csrf_token %}
85+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
86+
<button type="submit" class="btn btn-outline-danger" title="Discard">
87+
<i class="bi bi-trash"></i>
88+
</button>
89+
</form>
90+
</div>
91+
</div>
92+
</div>
93+
</div>
94+
{% endfor %}
95+
</div>
96+
{% endif %}
97+
4798
<h2 class="h5 text-muted mb-3">Projects</h2>
4899
{% if project_stats %}
49100
<div class="row g-3 mb-4">
@@ -62,14 +113,40 @@ <h2 class="h5 text-muted mb-3">Projects</h2>
62113
<div class="col"><div class="text-muted">Week</div><strong>{{ stat.week_h }}h</strong></div>
63114
<div class="col"><div class="text-muted">Month</div><strong>{{ stat.month_h }}h</strong></div>
64115
</div>
65-
{% if stat.active_timer %}
66-
<form method="post" action="{% url 'tracker:timer_stop' stat.active_timer.id %}">
67-
{% csrf_token %}
68-
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
69-
<button type="submit" class="btn btn-success w-100">
70-
<i class="bi bi-stop-circle me-1"></i>Stop Timer
71-
</button>
72-
</form>
116+
{% if stat.open_entry.is_running %}
117+
<div class="d-flex gap-2">
118+
<form method="post" action="{% url 'tracker:timer_pause' stat.open_entry.id %}" class="flex-grow-1">
119+
{% csrf_token %}
120+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
121+
<button type="submit" class="btn btn-outline-warning w-100">
122+
<i class="bi bi-pause-circle me-1"></i>Pause
123+
</button>
124+
</form>
125+
<form method="post" action="{% url 'tracker:timer_stop' stat.open_entry.id %}">
126+
{% csrf_token %}
127+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
128+
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
129+
<i class="bi bi-stop-circle"></i>
130+
</button>
131+
</form>
132+
</div>
133+
{% elif stat.open_entry.is_paused %}
134+
<div class="d-flex gap-2">
135+
<form method="post" action="{% url 'tracker:timer_resume' stat.open_entry.id %}" class="flex-grow-1">
136+
{% csrf_token %}
137+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
138+
<button type="submit" class="btn btn-outline-primary w-100">
139+
<i class="bi bi-play-circle me-1"></i>Resume
140+
</button>
141+
</form>
142+
<form method="post" action="{% url 'tracker:timer_stop' stat.open_entry.id %}">
143+
{% csrf_token %}
144+
<input type="hidden" name="next" value="{% url 'tracker:dashboard' %}">
145+
<button type="submit" class="btn btn-success" title="Stop &amp; Save">
146+
<i class="bi bi-stop-circle"></i>
147+
</button>
148+
</form>
149+
</div>
73150
{% else %}
74151
<form method="post" action="{% url 'tracker:timer_start' stat.project.id %}">
75152
{% csrf_token %}
@@ -142,8 +219,9 @@ <h2 class="h5 text-muted mb-3">Recent entries</h2>
142219
}
143220
function tick() {
144221
document.querySelectorAll(".js-timer").forEach(function (el) {
145-
var start = new Date(el.dataset.start);
146-
el.textContent = fmt(Date.now() - start.getTime());
222+
var accumulatedMs = (parseInt(el.dataset.accumulated, 10) || 0) * 60 * 1000;
223+
var segmentStart = new Date(el.dataset.segmentStart);
224+
el.textContent = fmt(accumulatedMs + (Date.now() - segmentStart.getTime()));
147225
});
148226
}
149227
setInterval(tick, 1000);

0 commit comments

Comments
 (0)