Add configurable due time suggestions

This commit is contained in:
rucki
2026-09-11 17:36:38 +02:00
parent 1cd2d06b4b
commit 504f58aa36
8 changed files with 90 additions and 7 deletions
+3 -1
View File
@@ -57,7 +57,7 @@ class UserSettingsForm(forms.ModelForm):
class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines', 'due_time_suggestions']
labels = {
'language': _('Language'),
'show_notes': _('Show notes'),
@@ -66,10 +66,12 @@ class UserPreferenceForm(forms.ModelForm):
'show_links': _('Show links'),
'due_first': _('Always show due items first'),
'overview_lines': _('Lines in overview'),
'due_time_suggestions': _('Time suggestions for tasks'),
}
widgets = {
'language': forms.Select(attrs={'class': 'form-select'}),
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
'due_time_suggestions': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': '08:00 Morgen\n12:00 Mittag\n17:00 Abend'}),
}
@@ -0,0 +1,18 @@
# Generated by Pi coding agent
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_item_assignee'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='due_time_suggestions',
field=models.TextField(blank=True, default=''),
),
]
+1
View File
@@ -355,6 +355,7 @@ class UserPreference(models.Model):
show_journal = models.BooleanField(default=False)
due_first = models.BooleanField(default=True)
overview_lines = models.PositiveSmallIntegerField(default=4)
due_time_suggestions = models.TextField(blank=True, default='')
def __str__(self):
return f'Einstellungen {self.user}'
+1 -1
View File
@@ -22,7 +22,7 @@
{% if item.visibility == 'team' %}<option value="team" selected>{% trans "Team" %}{% if item.team %}: {{ item.team.name }}{% endif %}</option>{% endif %}
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option>
</select></div>
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}">{% for option in due_time_options %}<option value="{{ option.value }}"{% if option.label %} label="{{ option.label }}"{% endif %}>{% endfor %}</datalist></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
+1 -1
View File
@@ -24,7 +24,7 @@
{% include 'core/_format_toolbar.html' %}
<div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>{% if item.visibility == 'team' %}<option value="team" selected>{% trans "Team" %}{% if item.team %}: {{ item.team.name }}{% endif %}</option>{% endif %}<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div>
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}">{% for option in due_time_options %}<option value="{{ option.value }}"{% if option.label %} label="{{ option.label }}"{% endif %}>{% endfor %}</datalist></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
+1
View File
@@ -44,6 +44,7 @@
<div class="form-label mb-2">{% trans "Sorting" %}</div>
<label class="form-check border rounded p-3"><input class="form-check-input" type="checkbox" name="due_first" {% if preference_form.due_first.value %}checked{% endif %}> <span class="form-check-label fw-semibold">{{ preference_form.due_first.label }}</span><div class="small text-muted">{% trans "Overdue and due-today tasks are sorted to the top of the overview." %}</div></label>
</div>
<div class="col-12"><label class="form-label">{{ preference_form.due_time_suggestions.label }}</label>{{ preference_form.due_time_suggestions }}<div class="form-text">{% trans "Optional: one time per line, with an optional label, e.g. '08:00 Morning'. Leave empty for 30-minute steps." %}</div></div>
</div>
<div>
<div class="form-label mb-2">{% trans "Show in overview" %}</div>
+34
View File
@@ -392,8 +392,42 @@ class NavigationAndLayoutTests(TestCase):
response = self.client.get(reverse('settings'))
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 2)
self.assertContains(response, 'name="due_time_suggestions"')
self.assertNotContains(response, 'name="default_item_kind"')
def test_default_due_time_suggestions_use_half_hour_steps(self):
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, '<option value="08:00">')
self.assertContains(response, '<option value="08:30">')
self.assertContains(response, '<option value="23:30">')
def test_custom_due_time_suggestions_are_used_in_edit_forms(self):
self.preferences.due_time_suggestions = '08:00 Morgen\n12:00 Mittag\n17:30 Abend'
self.preferences.save(update_fields=['due_time_suggestions'])
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, '<option value="08:00" label="Morgen">')
self.assertContains(response, '<option value="12:00" label="Mittag">')
self.assertNotContains(response, '<option value="08:30">')
def test_due_time_field_still_accepts_custom_time(self):
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
due_date = timezone.localdate().isoformat()
response = self.client.post(reverse('edit_item', args=[item.id]), {
'content': item.content, 'comment': '', 'visibility': Item.Visibility.PRIVATE,
'due_date': due_date, 'due_time': '10:17',
})
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
self.assertEqual(timezone.localtime(item.due_at).time().replace(second=0, microsecond=0), time(10, 17))
def test_composer_menu_is_before_toolbar_and_file_controls(self):
response = self.client.get(reverse('index'))
html = response.content.decode()
+31 -4
View File
@@ -147,6 +147,33 @@ def parse_due_command(raw):
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
def default_due_time_options():
return [f'{hour:02d}:{minute:02d}' for hour in range(24) for minute in (0, 30)]
def user_due_time_options(user):
try:
raw = user.preferences.due_time_suggestions
except Exception:
raw = ''
options = []
for line in (raw or '').splitlines():
line = line.strip()
if not line:
continue
match = re.match(r'^(\d{1,2}:\d{2})(?:\s+(.+))?$', line)
if not match:
continue
try:
parsed = time.fromisoformat(match.group(1))
except ValueError:
continue
value = parsed.strftime('%H:%M')
label = match.group(2).strip() if match.group(2) else ''
options.append({'value': value, 'label': label})
return options or [{'value': value, 'label': ''} for value in default_due_time_options()]
def parse_due_at_from_post(post):
due_date = (post.get('due_date') or '').strip()
due_time = (post.get('due_time') or '').strip()
@@ -1307,7 +1334,7 @@ def edit_item(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request))}, status=409)
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'due_time_options': user_due_time_options(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1319,7 +1346,7 @@ def edit_item(request, pk):
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged'
return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request))})
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'due_time_options': user_due_time_options(request.user)})
@login_required
@@ -1338,7 +1365,7 @@ def item_editor(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url}, status=409)
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url, 'due_time_options': user_due_time_options(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1348,7 +1375,7 @@ def item_editor(request, pk):
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url})
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url, 'due_time_options': user_due_time_options(request.user)})
@login_required