Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,12 @@
<input type="text" id="modify_effort" name="modify_effort" maxlength="4" /><?php echo(TodoLang::_("EFFORT_REMARK"));?>
</div><div class="modify_line">
<span class="modify_desc"><?php echo(TodoLang::_("RECURRENCE"));?>:</span>
<select name="modify_recurrenceMode" id="modify_recurrenceMode">
<input type="number" id="modify_recurrenceInterval" name="modify_recurrenceInterval" min="1" max="999" value="1" title="<?php echo(TodoLang::_("RECURRENCE_EVERY"));?>" /><select name="modify_recurrenceMode" id="modify_recurrenceMode">
<option value="0"><?php echo(TodoLang::_("DONT_REPEAT"));?></option>
<option value="3"><?php echo(TodoLang::_("REPEAT_EVERY_THREE_DAYS"));?></option>
<option value="7"><?php echo(TodoLang::_("REPEAT_WEEKLY"));?></option>
<option value="14"><?php echo(TodoLang::_("REPEAT_BIWEEKLY"));?></option>
<option value="30"><?php echo(TodoLang::_("REPEAT_MONTHLY"));?></option>
<option value="60"><?php echo(TodoLang::_("REPEAT_BIMONTHLY"));?></option>
<option value="91"><?php echo(TodoLang::_("REPEAT_QUARTERLY"));?></option>
<option value="121"><?php echo(TodoLang::_("REPEAT_THIRDOFYEARLY"));?></option>
<option value="182"><?php echo(TodoLang::_("REPEAT_HALFYEARLY"));?></option>
<option value="365"><?php echo(TodoLang::_("REPEAT_YEARLY"));?></option>
<option value="730"><?php echo(TodoLang::_("REPEAT_BIYEARLY"));?></option>
<option value="1095"><?php echo(TodoLang::_("REPEAT_THREEYEARLY"));?></option>
<option value="1460"><?php echo(TodoLang::_("REPEAT_FOURYEARLY"));?></option>
<option value="1805"><?php echo(TodoLang::_("REPEAT_FIVEYEARLY"));?></option>
<option value="1"><?php echo(TodoLang::_("REPEAT_UNIT_DAYS"));?></option>
<option value="2"><?php echo(TodoLang::_("REPEAT_UNIT_WEEKS"));?></option>
<option value="3"><?php echo(TodoLang::_("REPEAT_UNIT_MONTHS"));?></option>
<option value="4"><?php echo(TodoLang::_("REPEAT_UNIT_YEARS"));?></option>
</select>
</div><div class="modify_line recurrence_dependent">
<span class="modify_desc"><?php echo(TodoLang::_("RECURRENCE_ANCHOR"));?>:</span>
Expand Down
19 changes: 6 additions & 13 deletions lang/de-DE.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!"
Expand Down
19 changes: 6 additions & 13 deletions lang/en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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!"
Expand Down
12 changes: 6 additions & 6 deletions queries/reactivate-temp.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
require_once(__DIR__."/recurrence.php");
// copies all entries in the temporary table "reviving" as new, not completed entries;
// all values are copied inside the database, so they never become part of an SQL string
$creationDate = dbQueryOrDie($db, "SELECT UTC_TIMESTAMP()")->fetch_array()[0];
Expand All @@ -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));

Expand Down
23 changes: 7 additions & 16 deletions queries/reactivate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
14 changes: 14 additions & 0 deletions queries/recurrence.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
// recurrenceMode is the unit (0 = no recurrence), recurrenceInterval the number of units
const RECURRENCE_UNITS = array(1 => '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";
}
2 changes: 1 addition & 1 deletion queries/todo-list-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ".
Expand Down
12 changes: 9 additions & 3 deletions queries/update.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -56,6 +61,7 @@
"notes=?, ".
"version=?, ".
"recurrenceMode=?, ".
"recurrenceInterval=?, ".
"recurrenceAnchor=?, ".
"list_id=? ".
"WHERE id=? AND version=?";
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion sql/install.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
Expand Down
24 changes: 24 additions & 0 deletions sql/update-012.sql
Original file line number Diff line number Diff line change
@@ -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";
24 changes: 21 additions & 3 deletions tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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!';
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions todo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading