diff --git a/index.php b/index.php index 4e14dc3..582efa6 100644 --- a/index.php +++ b/index.php @@ -77,21 +77,12 @@
: - " />
: diff --git a/lang/de-DE.ini b/lang/de-DE.ini index f03a812..357c483 100644 --- a/lang/de-DE.ini +++ b/lang/de-DE.ini @@ -12,19 +12,11 @@ EFFORT="Aufwand" EFFORT_REMARK="(in Stunden)" RECURRENCE="Wiederholung" DONT_REPEAT="Nicht wiederholen" -REPEAT_EVERY_THREE_DAYS="Alle 3 Tage" -REPEAT_WEEKLY="Wöchentlich" -REPEAT_BIWEEKLY="Zwei-Wöchentlich" -REPEAT_MONTHLY="Monatlich" -REPEAT_BIMONTHLY="Zwei-Monatlich" -REPEAT_QUARTERLY="Einmal pro Quartal" -REPEAT_THIRDOFYEARLY="Einmal pro Jahresdrittel" -REPEAT_HALFYEARLY="Halbjährlich" -REPEAT_YEARLY="Jährlich" -REPEAT_BIYEARLY="Alle zwei Jahre" -REPEAT_THREEYEARLY="Alle drei Jahre" -REPEAT_FOURYEARLY="Alle vier Jahre" -REPEAT_FIVEYEARLY="Alle fünf Jahre" +RECURRENCE_EVERY="Alle" +REPEAT_UNIT_DAYS="Tag(e)" +REPEAT_UNIT_WEEKS="Woche(n)" +REPEAT_UNIT_MONTHS="Monat(e)" +REPEAT_UNIT_YEARS="Jahr(e)" RECURRENCE_ANCHOR="Beginnt ab" REPEAT_ANCHOR_COMPLETIONDATE="Fertigstellungs-Datum" REPEAT_ANCHOR_ENDDATE="Eingetragenes End-Datum" @@ -122,6 +114,7 @@ DOTODAY="Heute erledigen" EDITED_TAG_SUCCESSFUL="Tag erfolgreich geändert!" INVALID_EFFORT="Ungültiger Aufwand!" INVALID_RECURRENCE_MODE="Ungültige Wiederholung!" +INVALID_RECURRENCE_INTERVAL="Ungültiges Wiederholungs-Intervall!" INVALID_RECURRENCE_ANCHOR="Ungültiger Wiederholungs-Bezugspunkt!" DATABASE_ERROR="Datenbankfehler!" METHOD_NOT_ALLOWED="Methode nicht erlaubt!" diff --git a/lang/en-US.ini b/lang/en-US.ini index a42210c..639460b 100644 --- a/lang/en-US.ini +++ b/lang/en-US.ini @@ -12,19 +12,11 @@ EFFORT="Effort" EFFORT_REMARK="(in hours)" RECURRENCE="Recurrence" DONT_REPEAT="Don't repeat" -REPEAT_EVERY_THREE_DAYS="Every 3 days" -REPEAT_WEEKLY="Weekly" -REPEAT_BIWEEKLY="Bi-weekly" -REPEAT_MONTHLY="Monthly" -REPEAT_BIMONTHLY="Bi-monthly" -REPEAT_QUARTERLY="Once per quarter" -REPEAT_THIRDOFYEARLY="Once per a third of the year" -REPEAT_HALFYEARLY="Once every half year" -REPEAT_YEARLY="Once per year" -REPEAT_BIYEARLY="Every two years" -REPEAT_THREEYEARLY="Every three years" -REPEAT_FOURYEARLY="Every four years" -REPEAT_FIVEYEARLY="Every five years" +RECURRENCE_EVERY="Every" +REPEAT_UNIT_DAYS="day(s)" +REPEAT_UNIT_WEEKS="week(s)" +REPEAT_UNIT_MONTHS="month(s)" +REPEAT_UNIT_YEARS="year(s)" RECURRENCE_ANCHOR="Starts with" REPEAT_ANCHOR_COMPLETIONDATE="Completion date" REPEAT_ANCHOR_ENDDATE="Assigned end date" @@ -122,6 +114,7 @@ DOTODAY="Do today" EDITED_TAG_SUCCESSFUL="Tag changed successfully!" INVALID_EFFORT="Invalid effort!" INVALID_RECURRENCE_MODE="Invalid recurrence mode!" +INVALID_RECURRENCE_INTERVAL="Invalid recurrence interval!" INVALID_RECURRENCE_ANCHOR="Invalid recurrence anchor!" DATABASE_ERROR="Database error!" METHOD_NOT_ALLOWED="Method not allowed!" diff --git a/queries/reactivate-temp.php b/queries/reactivate-temp.php index 54a5d29..7b6e6e4 100644 --- a/queries/reactivate-temp.php +++ b/queries/reactivate-temp.php @@ -1,4 +1,5 @@ fetch_array()[0]; @@ -7,18 +8,17 @@ { // new due date: recurrence interval after completion (anchor 0) or after due date (anchor 1); // start date keeps its distance to the due date (or equals the due date if there is none) + $nextDue = recurrenceNextSql("IF(recurrenceAnchor=0, completionDate, dueDate)"); dbExec($db, "INSERT INTO todo ". "(creationDate, description, startDate, completed, ". - "dueDate, notes, version, recurrenceMode, recurrenceAnchor, list_id) ". + "dueDate, notes, version, recurrenceMode, recurrenceInterval, recurrenceAnchor, list_id) ". "SELECT ?, description, ". - "COALESCE(DATE_SUB(DATE_ADD(IF(recurrenceAnchor=0, completionDate, dueDate), ". - "INTERVAL recurrenceMode DAY), INTERVAL DATEDIFF(dueDate, startDate) DAY), ". - "DATE_ADD(IF(recurrenceAnchor=0, completionDate, dueDate), INTERVAL recurrenceMode DAY)), ". + "COALESCE(DATE_SUB(".$nextDue.", INTERVAL DATEDIFF(dueDate, startDate) DAY), ".$nextDue."), ". "0, ". // completed - "DATE_ADD(IF(recurrenceAnchor=0, completionDate, dueDate), INTERVAL recurrenceMode DAY), ". + $nextDue.", ". "notes, ". "1, ". // version - "recurrenceMode, recurrenceAnchor, list_id ". + "recurrenceMode, recurrenceInterval, recurrenceAnchor, list_id ". "FROM reviving WHERE id=?", array($creationDate, $toReactivate->id)); diff --git a/queries/reactivate.php b/queries/reactivate.php index b7c76ab..36841c8 100644 --- a/queries/reactivate.php +++ b/queries/reactivate.php @@ -2,26 +2,17 @@ // recurring events reactivation // currently called from query.php // would theoretically be enough to do this once per day or so: + require_once(__DIR__."/recurrence.php"); $sql = "CREATE TEMPORARY TABLE reviving AS ". "SELECT * FROM todo t WHERE completed=1 and ". "recurrenceMode != 0 AND ((". "recurrenceAnchor = 0 AND ". - "DATEDIFF(". - "DATE_ADD(". - "completionDate, ". - "INTERVAL recurrenceMode DAY". - "),". - "UTC_DATE()". - ") < GREATEST(DATEDIFF(dueDate, startDate), 4) ". - ") OR (". - "recurrenceAnchor = 1 AND ". - "DATEDIFF(". - "DATE_ADD(". - "dueDate, ". - "INTERVAL recurrenceMode DAY". - "),". - "UTC_DATE()". - ") < GREATEST(DATEDIFF(dueDate, startDate), 4) ". + "DATEDIFF(".recurrenceNextSql("completionDate").", UTC_DATE()) ". + "< GREATEST(DATEDIFF(dueDate, startDate), 4) ". + ") OR (". + "recurrenceAnchor = 1 AND ". + "DATEDIFF(".recurrenceNextSql("dueDate").", UTC_DATE()) ". + "< GREATEST(DATEDIFF(dueDate, startDate), 4) ". ")) AND NOT EXISTS (SELECT 1 FROM recurringCopied r WHERE r.todo_id=t.id);"; $qResult = dbQueryOrDie($db, $sql); require("reactivate-temp.php"); diff --git a/queries/recurrence.php b/queries/recurrence.php new file mode 100644 index 0000000..94d67dc --- /dev/null +++ b/queries/recurrence.php @@ -0,0 +1,14 @@ + 'DAY', 2 => 'WEEK', 3 => 'MONTH', 4 => 'YEAR'); +const MAX_RECURRENCE_INTERVAL = 999; + +// SQL expression: $baseExpr plus the recurrence interval of the todo row +function recurrenceNextSql($baseExpr) +{ + $sql = "CASE recurrenceMode "; + foreach (RECURRENCE_UNITS as $mode => $unit) { + $sql .= "WHEN ".$mode." THEN DATE_ADD(".$baseExpr.", INTERVAL recurrenceInterval ".$unit.") "; + } + return $sql."END"; +} diff --git a/queries/todo-list-query.php b/queries/todo-list-query.php index bc6b93e..20d801a 100644 --- a/queries/todo-list-query.php +++ b/queries/todo-list-query.php @@ -3,7 +3,7 @@ function todoListQuery($incomplete = true) { return "SELECT todo.id, description as todo, dueDate as due, startDate as start, effort, ". - "completed, notes, version, recurrenceMode, recurrenceAnchor, completionDate, ". + "completed, notes, version, recurrenceMode, recurrenceInterval, recurrenceAnchor, completionDate, ". "creationDate, deleted, ". "GROUP_CONCAT( DISTINCT name ORDER BY name SEPARATOR ',') as tags, list_id ". "FROM todo ". diff --git a/queries/update.php b/queries/update.php index fd22de2..ecb50bd 100644 --- a/queries/update.php +++ b/queries/update.php @@ -4,6 +4,7 @@ require("db.php"); require("date.php"); require("tags.php"); + require("recurrence.php"); $id = (int)postParam('id'); $todo = encodeInput(postParam('todo')); $due = postParam('due'); @@ -13,6 +14,7 @@ $tags = explode(",", postParam('tags')); $version = (int)postParam('version'); $recurrenceMode = (int)postParam('recurrenceMode'); + $recurrenceInterval = (int)postParam('recurrenceInterval'); $recurrenceAnchor = (int)postParam('recurrenceAnchor'); $list_id = (int)postParam('list_id'); requireOwnTodo($db, $id); @@ -39,11 +41,14 @@ echo TodoLang::_("INVALID_EFFORT"); die; } - // recurrence interval in days, at most 10 years: - if ($recurrenceMode < 0 || $recurrenceMode > 3650) { + if (!isset(RECURRENCE_UNITS[$recurrenceMode]) && $recurrenceMode != 0) { echo TodoLang::_("INVALID_RECURRENCE_MODE"); die; } + if ($recurrenceInterval < 1 || $recurrenceInterval > MAX_RECURRENCE_INTERVAL) { + echo TodoLang::_("INVALID_RECURRENCE_INTERVAL"); + die; + } if ($recurrenceAnchor != 0 && $recurrenceAnchor != 1) { echo TodoLang::_("INVALID_RECURRENCE_ANCHOR"); die; @@ -56,6 +61,7 @@ "notes=?, ". "version=?, ". "recurrenceMode=?, ". + "recurrenceInterval=?, ". "recurrenceAnchor=?, ". "list_id=? ". "WHERE id=? AND version=?"; @@ -64,7 +70,7 @@ ($start == '') ? NULL : $start, $effort, ($notes == '') ? NULL : $notes, - $version+1, $recurrenceMode, $recurrenceAnchor, $list_id, + $version+1, $recurrenceMode, $recurrenceInterval, $recurrenceAnchor, $list_id, $id, $version)); $affectedRows = $stmt->affected_rows; diff --git a/sql/install.sql b/sql/install.sql index f29fc82..a6326d4 100644 --- a/sql/install.sql +++ b/sql/install.sql @@ -10,6 +10,7 @@ CREATE TABLE `todo` ( `notes` varchar(10000) DEFAULT '', `version` int(11) NOT NULL DEFAULT 1, `recurrenceMode` int(11) NOT NULL DEFAULT 0, + `recurrenceInterval` int(11) NOT NULL DEFAULT 1, `recurrenceAnchor` int(1) NOT NULL DEFAULT 0, `deleted` tinyint(1) NOT NULL DEFAULT 0, `list_id` int(11) NOT NULL DEFAULT '0', @@ -47,7 +48,7 @@ CREATE TABLE `settings` ( PRIMARY KEY(`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -INSERT INTO `settings` (`key`, `value`) VALUES ("db.version", "11"); +INSERT INTO `settings` (`key`, `value`) VALUES ("db.version", "12"); -- default list of the default user (todo.list_id defaults to 0): SET SESSION sql_mode = CONCAT(@@sql_mode, ',NO_AUTO_VALUE_ON_ZERO'); diff --git a/sql/update-012.sql b/sql/update-012.sql new file mode 100644 index 0000000..a5cbbfd --- /dev/null +++ b/sql/update-012.sql @@ -0,0 +1,24 @@ +-- recurrenceMode used to be an interval in days; it is now the unit +-- (0 = none, 1 = days, 2 = weeks, 3 = months, 4 = years) and recurrenceInterval the count +ALTER TABLE `todo` ADD `recurrenceInterval` int(11) NOT NULL DEFAULT 1 AFTER `recurrenceMode`; + +-- recurrenceInterval first: it is computed from the old recurrenceMode value +UPDATE `todo` SET + `recurrenceInterval` = CASE + WHEN `recurrenceMode` = 0 THEN 1 + WHEN `recurrenceMode` IN (30, 60) THEN `recurrenceMode` / 30 + WHEN `recurrenceMode` = 91 THEN 3 + WHEN `recurrenceMode` = 121 THEN 4 + WHEN `recurrenceMode` = 182 THEN 6 + WHEN `recurrenceMode` = 1805 THEN 5 + WHEN `recurrenceMode` % 365 = 0 THEN `recurrenceMode` / 365 + WHEN `recurrenceMode` % 7 = 0 THEN `recurrenceMode` / 7 + ELSE `recurrenceMode` END, + `recurrenceMode` = CASE + WHEN `recurrenceMode` = 0 THEN 0 + WHEN `recurrenceMode` IN (30, 60, 91, 121, 182) THEN 3 + WHEN `recurrenceMode` = 1805 OR `recurrenceMode` % 365 = 0 THEN 4 + WHEN `recurrenceMode` % 7 = 0 THEN 2 + ELSE 1 END; + +UPDATE `settings` SET `value` = "12" WHERE `key` = "db.version"; diff --git a/tests/api.test.js b/tests/api.test.js index f61d84e..506f7ec 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -4,7 +4,7 @@ const { sql, sqlRows, resetDb, session, post, get } = require('./lib'); const todoFields = (over) => Object.assign({ todo: 'item', due: '', start: '', effort: 1, notes: '', tags: '', - recurrenceMode: 0, recurrenceAnchor: 0, list_id: 0 + recurrenceMode: 0, recurrenceInterval: 1, recurrenceAnchor: 0, list_id: 0 }, over); test.beforeEach(resetDb); @@ -42,7 +42,7 @@ test('reactivating a recurring entry copies stored values verbatim (no second-or const payload = ', NULL, 0, NULL, (SELECT CONCAT(user(), 0x20, version())), 1, 7, 1, 0)# '; const id = (await post('enter.php', { todo: 'x\\', due: '', start: '', tags: 't', list_id: 0 }, sess)).text; assert.equal((await post('update.php', todoFields({ id, version: 1, todo: 'x\\', notes: payload, - recurrenceMode: 7, recurrenceAnchor: 1, tags: 't' }), sess)).text, '1'); + recurrenceMode: 1, recurrenceInterval: 7, recurrenceAnchor: 1, tags: 't' }), sess)).text, '1'); assert.equal((await post('complete.php', { id, completed: 1, version: 2 }, sess)).text, '1'); assert.equal((await post('reactivate-one.php', { id }, sess)).text, 'Reactivated entry...'); const rows = sqlRows('SELECT description, notes, completed FROM todo ORDER BY id'); @@ -55,7 +55,7 @@ test('due recurring entries are reactivated when loading the list', async () => const sess = await session(); const id = (await post('enter.php', { todo: 'weekly', due: '2026-01-10', start: '2026-01-08', tags: 'w', list_id: 0 }, sess)).text; await post('update.php', todoFields({ id, version: 1, todo: 'weekly', due: '2026-01-10', start: '2026-01-08', - recurrenceMode: 7, recurrenceAnchor: 1, tags: 'w' }), sess); + recurrenceMode: 2, recurrenceInterval: 1, recurrenceAnchor: 1, tags: 'w' }), sess); await post('complete.php', { id, completed: 1, version: 2 }, sess); const res = await get('query-todos.php?list_id=0&age=10000&incomplete=true'); const items = JSON.parse(res.text); @@ -66,6 +66,21 @@ test('due recurring entries are reactivated when loading the list', async () => assert.equal(copy.tags, 'w'); }); +test('recurrence supports arbitrary intervals in days, weeks, months and years', async () => { + const sess = await session(); + const cases = [[1, 10, '2026-01-20'], [2, 3, '2026-01-31'], [3, 2, '2026-03-10'], [4, 10, '2036-01-10']]; + for (const [mode, interval, expectedDue] of cases) { + sql('DELETE FROM recurringCopied; DELETE FROM todo_tags; DELETE FROM todo'); + const id = (await post('enter.php', { todo: 'r', due: '2026-01-10', start: '2026-01-10', tags: '', list_id: 0 }, sess)).text; + assert.equal((await post('update.php', todoFields({ id, version: 1, todo: 'r', due: '2026-01-10', + start: '2026-01-10', recurrenceMode: mode, recurrenceInterval: interval, recurrenceAnchor: 1 }), sess)).text, '1'); + await post('complete.php', { id, completed: 1, version: 2 }, sess); + assert.equal((await post('reactivate-one.php', { id }, sess)).text, 'Reactivated entry...'); + assert.deepEqual(sqlRows('SELECT DATE(dueDate), recurrenceMode, recurrenceInterval FROM todo WHERE completed=0'), + [[expectedDue, String(mode), String(interval)]]); + } +}); + test('lists and todos of other users can neither be read nor changed', async () => { const sess = await session(); const denied = 'Access denied: this list or entry does not belong to you!'; @@ -99,6 +114,9 @@ test('invalid input is rejected', async () => { [{ due: '2026-01-01', start: '2026-01-05' }, 'Due date is earlier than start date!'], [{ effort: 10000 }, 'Invalid effort!'], [{ recurrenceMode: -5 }, 'Invalid recurrence mode!'], + [{ recurrenceMode: 5 }, 'Invalid recurrence mode!'], + [{ recurrenceMode: 2, recurrenceInterval: 0 }, 'Invalid recurrence interval!'], + [{ recurrenceMode: 2, recurrenceInterval: 1000 }, 'Invalid recurrence interval!'], [{ recurrenceAnchor: 2 }, 'Invalid recurrence anchor!'], ]; for (const [over, msg] of bad) { diff --git a/todo.css b/todo.css index 4368414..321807a 100644 --- a/todo.css +++ b/todo.css @@ -157,6 +157,14 @@ ul.ui-autocomplete { width: 300px; box-sizing: border-box; } +#modify_recurrenceInterval { + width: 70px; + box-sizing: border-box; + margin-right: 6px; +} +#modify_recurrenceMode { + width: 224px; +} .tagify { --tag-hide-transition: 0s; --readonly-striped: 0; @@ -383,6 +391,12 @@ ul.ui-autocomplete { #tag_dialog .tagify { width: 100%; } + #modify_recurrenceInterval { + width: 80px; + } + #modify_recurrenceMode { + width: calc(100% - 90px); + } /* larger touch targets */ .addButton, .editButton, .deleteButton, .undeleteButton, .todayButton, .refreshButton, .reactivateButton, .mergeButton { width: 32px; diff --git a/todo.js b/todo.js index 2ee841a..a18bcd2 100644 --- a/todo.js +++ b/todo.js @@ -14,7 +14,7 @@ var listsData = {user_id: 0}; function Todo(id, todo, due, start, effort, completed, notes, tags, deleted, - version, recurrenceMode, recurrenceAnchor, + version, recurrenceMode, recurrenceInterval, recurrenceAnchor, completionDate, creationDate, list_id) { this.id = parseInt(id); this.todo = todo; @@ -27,6 +27,7 @@ function Todo(id, todo, due, start, effort, this.deleted = parseInt(deleted); this.version = parseInt(version); this.recurrenceMode = parseInt(recurrenceMode); + this.recurrenceInterval = parseInt(recurrenceInterval); this.recurrenceAnchor = parseInt(recurrenceAnchor); this.completionDate = completionDate; this.creationDate = creationDate; @@ -38,7 +39,7 @@ function copyTodo(item) return new Todo( item.id, item.todo, item.due, item.start, item.effort, item.completed, item.notes, item.tags, item.deleted, - item.version, item.recurrenceMode, item.recurrenceAnchor, + item.version, item.recurrenceMode, item.recurrenceInterval, item.recurrenceAnchor, item.completionDate, item.creationDate, item.list_id ); } @@ -163,6 +164,7 @@ function modifyLocally(item) { itemList[index].tags = item.tags; itemList[index].version = item.version; itemList[index].recurrenceMode = item.recurrenceMode; + itemList[index].recurrenceInterval = item.recurrenceInterval; itemList[index].recurrenceAnchor = item.recurrenceAnchor; renderTable(); } @@ -399,10 +401,12 @@ function toggleRecurrenceAnchor(e) if (val == 0) { $('.recurrence_dependent').hide(); + $('#modify_recurrenceInterval').hide(); } else { $('.recurrence_dependent').show(); + $('#modify_recurrenceInterval').show(); } } @@ -441,6 +445,7 @@ function fillModifyForm(id) { $('#modify_recurrenceMode option:selected').prop('selected', false); $('#modify_recurrenceMode option[value="'+item.recurrenceMode+'"]').prop('selected', true); + $('#modify_recurrenceInterval').val(item.recurrenceInterval); $('#modify_recurrenceAnchor option:selected').prop('selected', false); $('#modify_recurrenceAnchor option[value="'+item.recurrenceAnchor+'"]').prop('selected', true); @@ -547,9 +552,14 @@ function setListener(id) { } -function getRecurrenceString(recurrenceMode) +function getRecurrenceString(recurrenceMode, recurrenceInterval) { - return $('#modify_recurrenceMode option[value="'+recurrenceMode+'"]').text(); + var unit = $('#modify_recurrenceMode option[value="'+recurrenceMode+'"]').text(); + if (recurrenceMode == 0) + { + return unit; + } + return $T('RECURRENCE_EVERY')+' '+recurrenceInterval+' '+unit; } @@ -686,6 +696,7 @@ function reload() { itemList[i].completed = parseInt(itemList[i].completed); itemList[i].version = parseInt(itemList[i].version); itemList[i].recurrenceMode = parseInt(itemList[i].recurrenceMode); + itemList[i].recurrenceInterval = parseInt(itemList[i].recurrenceInterval); itemList[i].recurrenceAnchor = parseInt(itemList[i].recurrenceAnchor); } renderTable(); @@ -812,6 +823,7 @@ function storeItem() { 0, // deleted items cannot be modified itemList[idx].version, $('#modify_recurrenceMode').val(), + $('#modify_recurrenceInterval').val(), $('#modify_recurrenceAnchor').val(), itemList[idx].completionDate, itemList[idx].creationDate, @@ -856,7 +868,7 @@ function enter() { due = null; } var stuff = new Todo(-1, todo, due, start, 1 /* effort */ , - 0, '', tags, 0, 1, 0 /* recurrenceMode */, 0 /* recurrenceAnchor */, + 0, '', tags, 0, 1, 0 /* recurrenceMode */, 1 /* recurrenceInterval */, 0 /* recurrenceAnchor */, null, formatDate(getUTCDate(), true), reloadData.list_id); addItem(stuff); @@ -898,7 +910,7 @@ function getTodoTitleHtml(it, lineNr, tagbasename, spanCssClass, baseElem, check var hasTags = it.tags != null && it.tags != ''; var createDate = parseDate(it.creationDate); // label of the recurrence option (DOM text), goes into an HTML attribute: - var repetition = escapeHtml(getRecurrenceString(it.recurrenceMode)); + var repetition = escapeHtml(getRecurrenceString(it.recurrenceMode, it.recurrenceInterval)); var complDate = parseDate(it.completionDate); line = '<'+baseElem+' class="'+spanCssClass+'" title="'+$T('CREATED')+': '+formatDate(createDate, true)+ '; '+$T('RECURRENCE')+': '+repetition+