Add configurable due time suggestions
This commit is contained in:
+3
-1
@@ -57,7 +57,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
class UserPreferenceForm(forms.ModelForm):
|
class UserPreferenceForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserPreference
|
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 = {
|
labels = {
|
||||||
'language': _('Language'),
|
'language': _('Language'),
|
||||||
'show_notes': _('Show notes'),
|
'show_notes': _('Show notes'),
|
||||||
@@ -66,10 +66,12 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
'show_links': _('Show links'),
|
'show_links': _('Show links'),
|
||||||
'due_first': _('Always show due items first'),
|
'due_first': _('Always show due items first'),
|
||||||
'overview_lines': _('Lines in overview'),
|
'overview_lines': _('Lines in overview'),
|
||||||
|
'due_time_suggestions': _('Time suggestions for tasks'),
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'language': forms.Select(attrs={'class': 'form-select'}),
|
'language': forms.Select(attrs={'class': 'form-select'}),
|
||||||
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
|
'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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -355,6 +355,7 @@ class UserPreference(models.Model):
|
|||||||
show_journal = models.BooleanField(default=False)
|
show_journal = models.BooleanField(default=False)
|
||||||
due_first = models.BooleanField(default=True)
|
due_first = models.BooleanField(default=True)
|
||||||
overview_lines = models.PositiveSmallIntegerField(default=4)
|
overview_lines = models.PositiveSmallIntegerField(default=4)
|
||||||
|
due_time_suggestions = models.TextField(blank=True, default='')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'Einstellungen {self.user}'
|
return f'Einstellungen {self.user}'
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% if item.visibility == 'team' %}<option value="team" selected>{% trans "Team" %}{% if item.team %}: {{ item.team.name }}{% endif %}</option>{% endif %}
|
{% 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>
|
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option>
|
||||||
</select></div>
|
</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 class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
|
||||||
</div>
|
</div>
|
||||||
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{% include 'core/_format_toolbar.html' %}
|
{% include 'core/_format_toolbar.html' %}
|
||||||
<div class="row g-2">
|
<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>
|
<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 class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
|
||||||
</div>
|
</div>
|
||||||
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<div class="form-label mb-2">{% trans "Sorting" %}</div>
|
<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>
|
<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>
|
||||||
|
<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>
|
<div>
|
||||||
<div class="form-label mb-2">{% trans "Show in overview" %}</div>
|
<div class="form-label mb-2">{% trans "Show in overview" %}</div>
|
||||||
|
|||||||
@@ -392,8 +392,42 @@ class NavigationAndLayoutTests(TestCase):
|
|||||||
response = self.client.get(reverse('settings'))
|
response = self.client.get(reverse('settings'))
|
||||||
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
|
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.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"')
|
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):
|
def test_composer_menu_is_before_toolbar_and_file_controls(self):
|
||||||
response = self.client.get(reverse('index'))
|
response = self.client.get(reverse('index'))
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|||||||
+31
-4
@@ -147,6 +147,33 @@ def parse_due_command(raw):
|
|||||||
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
|
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):
|
def parse_due_at_from_post(post):
|
||||||
due_date = (post.get('due_date') or '').strip()
|
due_date = (post.get('due_date') or '').strip()
|
||||||
due_time = (post.get('due_time') 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)
|
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):
|
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.'))
|
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.visibility, item.team = target_visibility, target_team
|
||||||
item.save()
|
item.save()
|
||||||
form.save_m2m()
|
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 = 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'
|
response['HX-Trigger'] = 'tagsChanged'
|
||||||
return response
|
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
|
@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)
|
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):
|
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.'))
|
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.visibility, item.team = target_visibility, target_team
|
||||||
item.save()
|
item.save()
|
||||||
form.save_m2m()
|
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)
|
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||||
assign_item_from_mentions(item)
|
assign_item_from_mentions(item)
|
||||||
return redirect(next_url)
|
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
|
@login_required
|
||||||
|
|||||||
Reference in New Issue
Block a user