feat(tasks): repeating tasks (daily, weekly, monthly, yearly)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m28s
- recurrence on tasks, set in the detail dialog; 🔁 on rows and Kanban cards - Completing a repeating task (detail, list, Kanban drag to done, note checkbox) creates the next one: title, tags, priority, notes, list, assignees and rule copied, subtasks reset, first open column, due date one interval later (month end clamped); pushed live as TASK_CREATED - Each task spawns at most once (re-open + complete makes no duplicate) - Export/import keep the rule (Obsidian Tasks "🔁 every week", JSON field) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e9fd1b3ad
commit
588c679c6a
16 files changed
+376
-19
No files matched your search
+1
-1
@@ -71,7 +71,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
|
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
|
||||||
|
|
||||||
|
## Wiederkehrende Aufgaben
|
||||||
|
|
||||||
|
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
|
||||||
|
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
|
||||||
|
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute). Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
|
||||||
|
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
|
||||||
|
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
|
||||||
|
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
|
||||||
|
|
||||||
## Alarme
|
## Alarme
|
||||||
|
|
||||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
||||||
|
|||||||
@@ -558,6 +558,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Zuweisen:</strong> In geteilten Aufgabenlisten weist du eine Aufgabe einer oder mehreren Personen mit Zugriff zu (Namen tippen, Enter). Die Zugewiesenen erscheinen als kleine Profilbilder an der Aufgabe; wer den Zugriff auf die Liste verliert, wird automatisch entfernt.
|
<strong className="text-main">Zuweisen:</strong> In geteilten Aufgabenlisten weist du eine Aufgabe einer oder mehreren Personen mit Zugriff zu (Namen tippen, Enter). Die Zugewiesenen erscheinen als kleine Profilbilder an der Aufgabe; wer den Zugriff auf die Liste verliert, wird automatisch entfernt.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-main">Wiederkehrende Aufgaben:</strong> Im Detail-Fenster eine Wiederholung wählen (täglich bis jährlich). Beim Abhaken entsteht automatisch die nächste Aufgabe mit fortgeschriebenem Datum; erkennbar am 🔁-Symbol.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
|
<strong className="text-main">Automatische Alarme:</strong> Ist ein Fälligkeitsdatum erreicht, kann MindSift dich automatisch per <strong className="text-main">E-Mail</strong>, <strong className="text-main">Discord-Webhook</strong> oder individuellem HTTP-Webhook benachrichtigen. Konfigurierbar in deinen Benutzereinstellungen.
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
Webhook,
|
Webhook,
|
||||||
Lock,
|
Lock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
|
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
@@ -34,6 +35,7 @@ interface TaskDetailModalProps {
|
|||||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||||
|
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
|
||||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||||
@@ -58,6 +60,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
onUpdatePriority,
|
onUpdatePriority,
|
||||||
onUpdateDueDate,
|
onUpdateDueDate,
|
||||||
|
onUpdateRecurrence,
|
||||||
onUpdateTaskList,
|
onUpdateTaskList,
|
||||||
onUpdateNotes,
|
onUpdateNotes,
|
||||||
onUpdateTags,
|
onUpdateTags,
|
||||||
@@ -509,6 +512,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
+1 Woche
|
+1 Woche
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{onUpdateRecurrence && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
|
||||||
|
<Repeat className="w-3 h-3 text-accent" />
|
||||||
|
<span>Wiederholung</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={task.recurrence || ''}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
|
||||||
|
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||||
|
>
|
||||||
|
<option value="">Keine Wiederholung</option>
|
||||||
|
<option value="daily">🔁 Täglich</option>
|
||||||
|
<option value="weekly">🔁 Wöchentlich</option>
|
||||||
|
<option value="monthly">🔁 Monatlich</option>
|
||||||
|
<option value="yearly">🔁 Jährlich</option>
|
||||||
|
</select>
|
||||||
|
{task.recurrence && (
|
||||||
|
<p className="text-[10px] text-faint mt-1">
|
||||||
|
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
|
||||||
|
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List assignment */}
|
{/* Task List assignment */}
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
Filter,
|
Filter,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
@@ -504,6 +505,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
await api.updateThought(task.id, { notes: newNotes });
|
await api.updateThought(task.id, { notes: newNotes });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
|
||||||
|
try {
|
||||||
|
await api.updateThought(task.id, { recurrence });
|
||||||
|
} catch {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||||
@@ -1306,6 +1316,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<span>{task.dueDate}</span>
|
<span>{task.dueDate}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{task.recurrence && (
|
||||||
|
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||||
|
<Repeat className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Hashtag badges */}
|
{/* Hashtag badges */}
|
||||||
{task.tags?.map((tag) => (
|
{task.tags?.map((tag) => (
|
||||||
@@ -2131,6 +2146,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onUpdateStatus={handleSetKanbanStatus}
|
onUpdateStatus={handleSetKanbanStatus}
|
||||||
onUpdatePriority={handleSetPriority}
|
onUpdatePriority={handleSetPriority}
|
||||||
onUpdateDueDate={handleUpdateTaskDueDate}
|
onUpdateDueDate={handleUpdateTaskDueDate}
|
||||||
|
onUpdateRecurrence={handleUpdateTaskRecurrence}
|
||||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||||
onUpdateNotes={handleUpdateTaskNotes}
|
onUpdateNotes={handleUpdateTaskNotes}
|
||||||
onUpdateTags={handleUpdateTaskTags}
|
onUpdateTags={handleUpdateTaskTags}
|
||||||
@@ -2324,6 +2340,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<span>{task.dueDate}</span>
|
<span>{task.dueDate}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{task.recurrence && (
|
||||||
|
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||||
|
<Repeat className="w-2.5 h-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{task.tags?.map((tag) => (
|
{task.tags?.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export const api = {
|
|||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
assignees?: string[];
|
assignees?: string[];
|
||||||
|
recurrence?: string;
|
||||||
}
|
}
|
||||||
): Promise<Thought> {
|
): Promise<Thought> {
|
||||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface SubTask {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repeat rule of a task: completing it creates the next one (due date one interval later)
|
||||||
|
export type Recurrence = '' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||||
|
|
||||||
|
export const RECURRENCE_LABELS: Record<Exclude<Recurrence, ''>, string> = {
|
||||||
|
daily: 'täglich',
|
||||||
|
weekly: 'wöchentlich',
|
||||||
|
monthly: 'monatlich',
|
||||||
|
yearly: 'jährlich',
|
||||||
|
};
|
||||||
|
|
||||||
export interface Thought {
|
export interface Thought {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -76,6 +86,7 @@ export interface Thought {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
assignees?: string[]; // user IDs, only people with access to the task list
|
assignees?: string[]; // user IDs, only people with access to the task list
|
||||||
|
recurrence?: Recurrence;
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
|||||||
notifier: collab.NewNotifier(),
|
notifier: collab.NewNotifier(),
|
||||||
}
|
}
|
||||||
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
|
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
|
||||||
|
// Next occurrences of repeating tasks appear live on every connected client
|
||||||
|
store.OnTaskCreated = func(th *domain.Thought) {
|
||||||
|
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
|
||||||
|
}
|
||||||
go srv.startAlarmTicker()
|
go srv.startAlarmTicker()
|
||||||
go srv.startCompaction()
|
go srv.startCompaction()
|
||||||
return srv
|
return srv
|
||||||
@@ -957,6 +961,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||||
Tags *[]string `json:"tags"`
|
Tags *[]string `json:"tags"`
|
||||||
Assignees *[]string `json:"assignees"`
|
Assignees *[]string `json:"assignees"`
|
||||||
|
Recurrence *string `json:"recurrence"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -964,6 +969,13 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
||||||
|
// The rule is set first, so completing a task in the same request already repeats it
|
||||||
|
if req.Recurrence != nil {
|
||||||
|
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
|
||||||
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreError(w, http.StatusBadRequest, err)
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ type Thought struct {
|
|||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Subtasks []SubTask `json:"subtasks"`
|
Subtasks []SubTask `json:"subtasks"`
|
||||||
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
|
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
|
||||||
|
Recurrence string `json:"recurrence,omitempty"` // "" | daily | weekly | monthly | yearly
|
||||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
|||||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||||
return scanThought(s.db.QueryRow(
|
return scanThought(s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts WHERE id = ?`, thoughtID))
|
FROM thoughts WHERE id = ?`, thoughtID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obsidian Tasks writes repeat rules as "🔁 every week"
|
||||||
|
var recurrenceEvery = map[string]string{"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}
|
||||||
|
|
||||||
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
|
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
|
||||||
// with its subtasks and notes indented below.
|
// with its subtasks and notes indented below.
|
||||||
func taskMarkdownLine(t domain.Thought) string {
|
func taskMarkdownLine(t domain.Thought) string {
|
||||||
@@ -134,6 +137,9 @@ func taskMarkdownLine(t domain.Thought) string {
|
|||||||
case "low":
|
case "low":
|
||||||
line += " 🔽"
|
line += " 🔽"
|
||||||
}
|
}
|
||||||
|
if every, ok := recurrenceEvery[t.Recurrence]; ok {
|
||||||
|
line += " 🔁 every " + every
|
||||||
|
}
|
||||||
if t.DueDate != "" {
|
if t.DueDate != "" {
|
||||||
line += " 📅 " + t.DueDate
|
line += " 📅 " + t.DueDate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ type importDoc struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type importTask struct {
|
type importTask struct {
|
||||||
content, priority, due, notes string
|
content, priority, due, notes, recurrence string
|
||||||
done bool
|
done bool
|
||||||
subtasks []domain.SubTask
|
subtasks []domain.SubTask
|
||||||
}
|
}
|
||||||
|
|
||||||
type importTaskList struct {
|
type importTaskList struct {
|
||||||
@@ -393,7 +393,7 @@ func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
|
|||||||
plan.TaskLists = append(plan.TaskLists, importTaskList{title: tl.Title, icon: tl.Icon})
|
plan.TaskLists = append(plan.TaskLists, importTaskList{title: tl.Title, icon: tl.Icon})
|
||||||
}
|
}
|
||||||
for _, t := range exp.Thoughts {
|
for _, t := range exp.Thoughts {
|
||||||
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks}
|
task := importTask{content: t.Content, priority: t.Priority, due: t.DueDate, notes: t.Notes, done: t.IsCompleted, subtasks: t.Subtasks, recurrence: t.Recurrence}
|
||||||
switch {
|
switch {
|
||||||
case t.Status == domain.StatusInbox:
|
case t.Status == domain.StatusInbox:
|
||||||
plan.Inbox = append(plan.Inbox, task)
|
plan.Inbox = append(plan.Inbox, task)
|
||||||
@@ -506,6 +506,10 @@ func (s *Store) importTaskInto(userID, listID string, t importTask, asTask bool)
|
|||||||
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", t.due, listID); err != nil {
|
if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", t.due, listID); err != nil {
|
||||||
return fmt.Errorf("Aufgabe %q: %w", content, err)
|
return fmt.Errorf("Aufgabe %q: %w", content, err)
|
||||||
}
|
}
|
||||||
|
if validRecurrence(t.recurrence) && t.recurrence != "" {
|
||||||
|
// Imported as already handled: completing a restored task must not create a second successor
|
||||||
|
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, recurrence_spawned = ? WHERE id = ?`, t.recurrence, boolInt(t.done), th.ID)
|
||||||
|
}
|
||||||
if t.done || len(t.subtasks) > 0 {
|
if t.done || len(t.subtasks) > 0 {
|
||||||
var done *bool
|
var done *bool
|
||||||
if t.done {
|
if t.done {
|
||||||
@@ -754,6 +758,7 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
recurRe = regexp.MustCompile(`(?i)[ \t]*🔁[ \t]*every[ \t]+(day|week|month|year)\b`)
|
||||||
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})`)
|
dueRe = regexp.MustCompile(`[ \t]*📅[ \t]*(\d{4}-\d{2}-\d{2})`)
|
||||||
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
|
priorityRe = regexp.MustCompile(`[ \t]*(🔺|⏫|🔼|🔽|⏬)`)
|
||||||
)
|
)
|
||||||
@@ -790,6 +795,10 @@ func parseTaskFile(body string) []importTask {
|
|||||||
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
|
if c := checkboxRe.FindStringSubmatch(m[2]); c != nil {
|
||||||
t.done, t.content = c[1] != " ", c[2]
|
t.done, t.content = c[1] != " ", c[2]
|
||||||
}
|
}
|
||||||
|
if r := recurRe.FindStringSubmatch(t.content); r != nil {
|
||||||
|
t.recurrence = map[string]string{"day": "daily", "week": "weekly", "month": "monthly", "year": "yearly"}[strings.ToLower(r[1])]
|
||||||
|
t.content = recurRe.ReplaceAllString(t.content, "")
|
||||||
|
}
|
||||||
if d := dueRe.FindStringSubmatch(t.content); d != nil {
|
if d := dueRe.FindStringSubmatch(t.content); d != nil {
|
||||||
t.due = d[1]
|
t.due = d[1]
|
||||||
t.content = dueRe.ReplaceAllString(t.content, "")
|
t.content = dueRe.ReplaceAllString(t.content, "")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func seedAnna(t *testing.T, store *Store, anna *domain.User) {
|
|||||||
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
|
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
|
||||||
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
|
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
|
||||||
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
||||||
|
_ = store.SetTaskRecurrence(anna.ID, task.ID, "monthly")
|
||||||
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
||||||
item, _ := store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
|
item, _ := store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
|
||||||
_, _ = store.ToggleListItem(item.ID, true, anna.ID, "Anna")
|
_, _ = store.ToggleListItem(item.ID, true, anna.ID, "Anna")
|
||||||
@@ -66,7 +67,7 @@ func checkRestored(t *testing.T, store *Store, ben *domain.User) {
|
|||||||
t.Fatalf("task list not restored: %+v / %+v", lists, tasks)
|
t.Fatalf("task list not restored: %+v / %+v", lists, tasks)
|
||||||
}
|
}
|
||||||
tk := tasks[0]
|
tk := tasks[0]
|
||||||
if tk.Content != "Fenster putzen" || tk.Priority != "urgent" || tk.DueDate != "2026-11-02" || tk.Notes != "Leiter im Keller" ||
|
if tk.Content != "Fenster putzen" || tk.Priority != "urgent" || tk.DueDate != "2026-11-02" || tk.Notes != "Leiter im Keller" || tk.Recurrence != "monthly" ||
|
||||||
len(tk.Subtasks) != 1 || !tk.Subtasks[0].IsCompleted || tk.TaskListID != lists[0].ID {
|
len(tk.Subtasks) != 1 || !tk.Subtasks[0].IsCompleted || tk.TaskListID != lists[0].ID {
|
||||||
t.Errorf("task not restored: %+v", tk)
|
t.Errorf("task not restored: %+v", tk)
|
||||||
}
|
}
|
||||||
@@ -218,4 +219,3 @@ func TestImportBackupZip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
checkRestored(t, store, ben)
|
checkRestored(t, store, ben)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidRecurrence is returned for unknown repeat rules.
|
||||||
|
var ErrInvalidRecurrence = errors.New("Unbekannte Wiederholung (erlaubt: täglich, wöchentlich, monatlich, jährlich)")
|
||||||
|
|
||||||
|
func validRecurrence(r string) bool {
|
||||||
|
switch r {
|
||||||
|
case "", "daily", "weekly", "monthly", "yearly":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextDueDate moves a due date (YYYY-MM-DD, or today when empty) one interval forward. Months and years
|
||||||
|
// keep the day where possible and fall back to the month's last day (31 Jan -> 28/29 Feb).
|
||||||
|
func NextDueDate(due, rule string, today time.Time) string {
|
||||||
|
base, err := time.Parse("2006-01-02", due)
|
||||||
|
if err != nil {
|
||||||
|
base = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
switch rule {
|
||||||
|
case "daily":
|
||||||
|
base = base.AddDate(0, 0, 1)
|
||||||
|
case "weekly":
|
||||||
|
base = base.AddDate(0, 0, 7)
|
||||||
|
case "monthly":
|
||||||
|
base = addMonthsClamped(base, 1)
|
||||||
|
case "yearly":
|
||||||
|
base = addMonthsClamped(base, 12)
|
||||||
|
}
|
||||||
|
return base.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMonthsClamped(t time.Time, months int) time.Time {
|
||||||
|
first := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, months, 0)
|
||||||
|
last := first.AddDate(0, 1, -1).Day()
|
||||||
|
day := t.Day()
|
||||||
|
if day > last {
|
||||||
|
day = last
|
||||||
|
}
|
||||||
|
return time.Date(first.Year(), first.Month(), day, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTaskRecurrence changes the repeat rule of a task (editors of its list or its owner).
|
||||||
|
func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
|
||||||
|
if !validRecurrence(rule) {
|
||||||
|
return ErrInvalidRecurrence
|
||||||
|
}
|
||||||
|
t, err := s.GetThoughtByID(userID, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureTaskListWritable(userID, t.TaskListID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`UPDATE thoughts SET recurrence = ? WHERE id = ?`, rule, taskID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
||||||
|
t, err := scanThought(s.db.QueryRow(
|
||||||
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
|
FROM thoughts WHERE id = ?`, id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Assignees = s.getTaskAssignees(t.ID)
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnNextOccurrence creates the follow-up of a repeating task that was just completed: same title,
|
||||||
|
// tags, priority, notes, list, assignees and rule, subtasks unchecked, due date one interval later,
|
||||||
|
// in the list's first open column. Each task spawns at most once, so re-opening and completing it
|
||||||
|
// again creates no duplicate. The new task goes to OnTaskCreated (live update for all clients).
|
||||||
|
func (s *Store) spawnNextOccurrence(taskID string) {
|
||||||
|
t, err := s.getThoughtRaw(taskID)
|
||||||
|
if err != nil || t.Recurrence == "" || !t.IsCompleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(`UPDATE thoughts SET recurrence_spawned = 1 WHERE id = ? AND recurrence_spawned = 0`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return // already spawned its successor
|
||||||
|
}
|
||||||
|
|
||||||
|
column := "todo"
|
||||||
|
if t.TaskListID != "" {
|
||||||
|
if tl, err := s.GetTaskListByID(t.UserID, t.TaskListID); err == nil {
|
||||||
|
for _, c := range tl.Columns {
|
||||||
|
if !c.IsDone {
|
||||||
|
column = c.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
due := NextDueDate(t.DueDate, t.Recurrence, time.Now())
|
||||||
|
next, err := s.CreateThoughtWithDetails(t.UserID, t.Content, t.Priority, t.Source, due, t.TaskListID, column, t.Notes)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.UpdateThoughtTriage(t.UserID, next.ID, "keep", "task", "", due, t.TaskListID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subs := make([]domain.SubTask, len(t.Subtasks))
|
||||||
|
for i, st := range t.Subtasks {
|
||||||
|
subs[i] = domain.SubTask{ID: domain.NewID("st"), Title: st.Title, CreatedAt: domain.NowISO()}
|
||||||
|
}
|
||||||
|
tags := t.Tags
|
||||||
|
_, _ = s.UpdateThoughtDetails(t.UserID, next.ID, nil, nil, nil, nil, nil, &column, nil, &subs, &tags)
|
||||||
|
_, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, sort_order = ? WHERE id = ?`, t.Recurrence, t.SortOrder, next.ID)
|
||||||
|
for _, uid := range t.Assignees {
|
||||||
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, next.ID, uid, domain.NowISO())
|
||||||
|
}
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
|
||||||
|
if created, err := s.getThoughtRaw(next.ID); err == nil && s.OnTaskCreated != nil {
|
||||||
|
s.OnTaskCreated(created)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNextDueDate(t *testing.T) {
|
||||||
|
today := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct{ due, rule, want string }{
|
||||||
|
{"2026-10-30", "daily", "2026-10-31"},
|
||||||
|
{"2026-10-30", "weekly", "2026-11-06"},
|
||||||
|
{"2026-01-31", "monthly", "2026-02-28"},
|
||||||
|
{"2026-03-15", "monthly", "2026-04-15"},
|
||||||
|
{"2028-02-29", "yearly", "2029-02-28"},
|
||||||
|
{"", "weekly", "2026-11-05"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := NextDueDate(c.due, c.rule, today); got != c.want {
|
||||||
|
t.Errorf("NextDueDate(%q, %s) = %s, want %s", c.due, c.rule, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepeatingTaskSpawnsItsSuccessorOnce(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "recur.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
openSharing(t, store)
|
||||||
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
|
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
|
||||||
|
_, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID)
|
||||||
|
|
||||||
|
var pushed []*domain.Thought
|
||||||
|
store.OnTaskCreated = func(th *domain.Thought) { pushed = append(pushed, th) }
|
||||||
|
|
||||||
|
task, _ := store.CreateThoughtWithDetails(anna.ID, "Müll rausbringen #haushalt", "high", "manual", "2026-11-02", tl.ID, "todo", "Gelbe Tonne")
|
||||||
|
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
|
||||||
|
subs := []domain.SubTask{{ID: "s1", Title: "Tüten", IsCompleted: true}}
|
||||||
|
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
||||||
|
_, _ = store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID})
|
||||||
|
if err := store.SetTaskRecurrence(anna.ID, task.ID, "weekly"); err != nil {
|
||||||
|
t.Fatalf("SetTaskRecurrence: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetTaskRecurrence(anna.ID, task.ID, "stündlich"); err == nil {
|
||||||
|
t.Errorf("unknown rule must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
done := true
|
||||||
|
if _, err := store.UpdateThoughtDetails(ben.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if len(pushed) != 1 {
|
||||||
|
t.Fatalf("expected one successor pushed live, got %d", len(pushed))
|
||||||
|
}
|
||||||
|
next := pushed[0]
|
||||||
|
if next.Content != "Müll rausbringen #haushalt" || next.Priority != "high" || next.Notes != "Gelbe Tonne" ||
|
||||||
|
next.DueDate != "2026-11-09" || next.Recurrence != "weekly" || next.IsCompleted || next.TaskListID != tl.ID ||
|
||||||
|
next.KanbanStatus != "todo" || len(next.Subtasks) != 1 || next.Subtasks[0].IsCompleted ||
|
||||||
|
len(next.Assignees) != 1 || next.Assignees[0] != ben.ID || next.UserID != anna.ID {
|
||||||
|
t.Errorf("successor wrong: %+v", next)
|
||||||
|
}
|
||||||
|
if orig, _ := store.GetThoughtByID(anna.ID, task.ID); !orig.IsCompleted {
|
||||||
|
t.Errorf("the completed task stays completed for the history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-opening and completing again creates no duplicate
|
||||||
|
open := false
|
||||||
|
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &open, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
if len(pushed) != 1 {
|
||||||
|
t.Fatalf("re-completing must not spawn again, got %d successors", len(pushed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging the successor into "done" on the Kanban board repeats as well
|
||||||
|
status := "done"
|
||||||
|
if err := store.ReorderTasks(tl.ID, []domain.ThoughtReorderItem{{ID: next.ID, SortOrder: 0, KanbanStatus: &status}}); err != nil {
|
||||||
|
t.Fatalf("ReorderTasks: %v", err)
|
||||||
|
}
|
||||||
|
if len(pushed) != 2 || pushed[1].DueDate != "2026-11-16" {
|
||||||
|
t.Fatalf("Kanban completion should spawn the next week, got %+v", pushed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task without rule never repeats
|
||||||
|
plain, _ := store.CreateThoughtWithDetails(anna.ID, "Einmalig", "normal", "manual", "", tl.ID, "todo", "")
|
||||||
|
_, _ = store.UpdateThoughtTriage(anna.ID, plain.ID, "keep", "task", "", "", tl.ID)
|
||||||
|
_, _ = store.UpdateThoughtDetails(anna.ID, plain.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
if len(pushed) != 2 {
|
||||||
|
t.Errorf("non-repeating task must not spawn")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ import (
|
|||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
initMu sync.Mutex
|
initMu sync.Mutex
|
||||||
|
|
||||||
|
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
|
||||||
|
// task), so the server can push them to connected clients.
|
||||||
|
OnTaskCreated func(*domain.Thought)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore(dbPath string) (*Store, error) {
|
func NewStore(dbPath string) (*Store, error) {
|
||||||
@@ -454,6 +458,10 @@ func (s *Store) migrate() error {
|
|||||||
|
|
||||||
// Who added an item to a (shopping) list
|
// Who added an item to a (shopping) list
|
||||||
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
|
||||||
|
|
||||||
|
// Repeating tasks: daily | weekly | monthly | yearly
|
||||||
|
"ALTER TABLE thoughts ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';",
|
||||||
|
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -938,7 +946,7 @@ func scanThought(rows interface {
|
|||||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder,
|
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -981,7 +989,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
|||||||
|
|
||||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||||
thoughtID, userID, userID, userID, userID,
|
thoughtID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
@@ -1002,14 +1010,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
|||||||
|
|
||||||
if statusFilter != "" && statusFilter != "all" {
|
if statusFilter != "" && statusFilter != "all" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts WHERE user_id = ? AND status = ?
|
FROM thoughts WHERE user_id = ? AND status = ?
|
||||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, statusFilter, limit,
|
userID, statusFilter, limit,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts WHERE user_id = ?
|
FROM thoughts WHERE user_id = ?
|
||||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||||
userID, limit,
|
userID, limit,
|
||||||
@@ -1035,7 +1043,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
var err error
|
var err error
|
||||||
if taskListID != "" {
|
if taskListID != "" {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND task_list_id = ?
|
AND task_list_id = ?
|
||||||
@@ -1045,7 +1053,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rows, err = s.db.Query(
|
rows, err = s.db.Query(
|
||||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
|
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
|
||||||
FROM thoughts
|
FROM thoughts
|
||||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||||
@@ -1076,11 +1084,17 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
||||||
isCompleted := 0
|
isCompleted := 0
|
||||||
if *it.KanbanStatus == "done" {
|
if *it.KanbanStatus == "done" {
|
||||||
isCompleted = 1
|
isCompleted = 1
|
||||||
|
var was int
|
||||||
|
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
||||||
|
if was == 0 {
|
||||||
|
completed = append(completed, it.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
|
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`,
|
||||||
@@ -1097,7 +1111,13 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range completed {
|
||||||
|
s.spawnNextOccurrence(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||||||
@@ -1231,6 +1251,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
wasCompleted := thought.IsCompleted
|
||||||
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1303,6 +1324,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
|||||||
s.PruneTaskAssignees()
|
s.PruneTaskAssignees()
|
||||||
thought.Assignees = s.getTaskAssignees(thoughtID)
|
thought.Assignees = s.getTaskAssignees(thoughtID)
|
||||||
}
|
}
|
||||||
|
if !wasCompleted && thought.IsCompleted {
|
||||||
|
s.spawnNextOccurrence(thoughtID)
|
||||||
|
}
|
||||||
return thought, nil
|
return thought, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3942,7 +3966,12 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
|||||||
doneInt = 1
|
doneInt = 1
|
||||||
kanban = "done"
|
kanban = "done"
|
||||||
}
|
}
|
||||||
_, _ = s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ?`, doneInt, kanban, taskID, userID)
|
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ? WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, taskID, userID, doneInt)
|
||||||
|
if checked && res != nil {
|
||||||
|
if n, _ := res.RowsAffected(); n > 0 {
|
||||||
|
s.spawnNextOccurrence(taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||||
|
|||||||
Reference in new issue
Block a user