Skip to content

Commit 14dfd56

Browse files
committed
feat: implement task history management and improve task loading logic
1 parent 45de962 commit 14dfd56

1 file changed

Lines changed: 72 additions & 37 deletions

File tree

python/src/task_stack/stack.py

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
STACK_FILE = Path.home() / ".task-stack.yaml"
1111
_TMP = STACK_FILE.with_suffix(".yaml.tmp")
12+
HISTORY_FILE = Path.home() / ".task-stack.history.yaml"
13+
_HISTORY_TMP = HISTORY_FILE.with_suffix(".history.yaml.tmp")
1214

1315

1416
@dataclass
@@ -142,30 +144,54 @@ def _migrate_durations(tasks: list[Task]) -> bool:
142144
return changed
143145

144146

145-
def _load_all() -> list[Task]:
146-
"""Load every task from disk, including soft-deleted ones."""
147+
def _load_active() -> list[Task]:
148+
"""Load active (non-deleted) tasks from the main stack file.
149+
150+
Handles two legacy migrations on first encounter:
151+
- inline deleted tasks: moved to the history file
152+
- missing duration fields: backfilled from started_at/last_current
153+
"""
147154
if not STACK_FILE.exists():
148155
return []
149156
try:
150157
data = yaml.safe_load(STACK_FILE.read_text())
151158
except Exception:
152159
return []
153-
tasks = _parse_tasks(data)
154-
if _migrate_durations(tasks):
160+
all_tasks = _parse_tasks(data)
161+
active = [t for t in all_tasks if not t.is_deleted]
162+
inline_deleted = [t for t in all_tasks if t.is_deleted]
163+
164+
duration_changed = _migrate_durations(active)
165+
166+
if inline_deleted:
167+
history = _load_history()
168+
history.extend(inline_deleted)
155169
try:
156-
_save_all(tasks)
170+
_save_history(history)
171+
_save_active(active)
172+
except Exception:
173+
pass
174+
elif duration_changed:
175+
try:
176+
_save_active(active)
157177
except Exception:
158178
pass
159-
return tasks
160179

180+
return active
161181

162-
def _split(tasks: list[Task]) -> tuple[list[Task], list[Task]]:
163-
active = [t for t in tasks if not t.is_deleted]
164-
deleted = [t for t in tasks if t.is_deleted]
165-
return active, deleted
182+
183+
def _load_history() -> list[Task]:
184+
"""Load soft-deleted tasks from the history file."""
185+
if not HISTORY_FILE.exists():
186+
return []
187+
try:
188+
data = yaml.safe_load(HISTORY_FILE.read_text())
189+
except Exception:
190+
return []
191+
return _parse_tasks(data)
166192

167193

168-
def _save_all(tasks: list[Task]) -> None:
194+
def _save_active(tasks: list[Task]) -> None:
169195
data = yaml.safe_dump(
170196
[t.to_dict() for t in tasks],
171197
sort_keys=False,
@@ -176,39 +202,48 @@ def _save_all(tasks: list[Task]) -> None:
176202
os.replace(_TMP, STACK_FILE)
177203

178204

179-
def _commit(active: list[Task], deleted: list[Task]) -> list[Task]:
180-
"""Persist active+deleted (deleted appended at the end) and return the active list."""
181-
_save_all(active + deleted)
205+
def _save_history(tasks: list[Task]) -> None:
206+
data = yaml.safe_dump(
207+
[t.to_dict() for t in tasks],
208+
sort_keys=False,
209+
allow_unicode=True,
210+
default_flow_style=False,
211+
)
212+
_HISTORY_TMP.write_text(data)
213+
os.replace(_HISTORY_TMP, HISTORY_FILE)
214+
215+
216+
def _commit(active: list[Task]) -> list[Task]:
217+
_save_active(active)
182218
return active
183219

184220

185221
def load() -> list[Task]:
186222
"""Return the active (non-deleted) task stack."""
187-
return [t for t in _load_all() if not t.is_deleted]
223+
return _load_active()
188224

189225

190226
def save(tasks: list[Task]) -> None:
191227
"""Replace the active task stack on disk, preserving any soft-deleted history."""
192-
_, deleted = _split(_load_all())
193-
_save_all(tasks + deleted)
228+
_save_active(tasks)
194229

195230

196231
def deleted() -> list[Task]:
197232
"""Return the soft-deleted history, oldest deletions first."""
198-
history = [t for t in _load_all() if t.is_deleted]
233+
history = _load_history()
199234
history.sort(key=lambda t: t.deleted_at or datetime.min.replace(tzinfo=timezone.utc))
200235
return history
201236

202237

203238
def push(text: str) -> list[Task]:
204239
now = datetime.now().astimezone()
205-
active, history = _split(_load_all())
240+
active = _load_active()
206241
if active:
207242
active[0].end_current_stint(now)
208243
task = Task(text=text.strip(), created_at=now)
209244
task.mark_current(now)
210245
active.insert(0, task)
211-
return _commit(active, history)
246+
return _commit(active)
212247

213248

214249
def push_next(text: str) -> list[Task]:
@@ -218,44 +253,44 @@ def push_next(text: str) -> list[Task]:
218253
becomes current (same as ``push``).
219254
"""
220255
now = datetime.now().astimezone()
221-
active, history = _split(_load_all())
256+
active = _load_active()
222257
task = Task(text=text.strip(), created_at=now)
223258
if not active:
224259
task.mark_current(now)
225260
active.insert(0, task)
226261
else:
227262
active.insert(1, task)
228-
return _commit(active, history)
263+
return _commit(active)
229264

230265

231266
def push_last(text: str) -> list[Task]:
232267
"""Insert a new task at the bottom of the active list."""
233268
now = datetime.now().astimezone()
234-
active, history = _split(_load_all())
269+
active = _load_active()
235270
task = Task(text=text.strip(), created_at=now)
236271
if not active:
237272
task.mark_current(now)
238273
active.append(task)
239-
return _commit(active, history)
274+
return _commit(active)
240275

241276

242277
def pop() -> tuple[Task | None, list[Task]]:
243278
now = datetime.now().astimezone()
244-
active, history = _split(_load_all())
279+
active = _load_active()
245280
if not active:
246281
return None, []
247282
removed = active.pop(0)
248283
removed.end_current_stint(now)
249284
removed.deleted_at = now
250-
history.append(removed)
285+
_save_history(_load_history() + [removed])
251286
if active:
252287
active[0].mark_current(now)
253-
return removed, _commit(active, history)
288+
return removed, _commit(active)
254289

255290

256291
def reorder(from_idx: int, to_idx: int) -> list[Task]:
257292
now = datetime.now().astimezone()
258-
active, history = _split(_load_all())
293+
active = _load_active()
259294
if from_idx == to_idx or not (0 <= from_idx < len(active)) or not (0 <= to_idx < len(active)):
260295
return active
261296
if from_idx == 0 and to_idx != 0:
@@ -266,7 +301,7 @@ def reorder(from_idx: int, to_idx: int) -> list[Task]:
266301
active.insert(to_idx, task)
267302
if to_idx == 0:
268303
active[0].mark_current(now)
269-
return _commit(active, history)
304+
return _commit(active)
270305

271306

272307
def promote(idx: int) -> list[Task]:
@@ -281,36 +316,36 @@ def update_text(idx: int, text: str) -> list[Task]:
281316
"""
282317
new_text = text.strip()
283318
if not new_text:
284-
return [t for t in _load_all() if not t.is_deleted]
285-
active, history = _split(_load_all())
319+
return _load_active()
320+
active = _load_active()
286321
if not (0 <= idx < len(active)):
287322
return active
288323
active[idx].text = new_text
289-
return _commit(active, history)
324+
return _commit(active)
290325

291326

292327
def update_description(idx: int, description: str) -> list[Task]:
293328
"""Update the description of the active task at ``idx`` in place."""
294-
active, history = _split(_load_all())
329+
active = _load_active()
295330
if not (0 <= idx < len(active)):
296331
return active
297332
active[idx].description = description
298-
return _commit(active, history)
333+
return _commit(active)
299334

300335

301336
def remove(idx: int) -> list[Task]:
302337
now = datetime.now().astimezone()
303-
active, history = _split(_load_all())
338+
active = _load_active()
304339
if not (0 <= idx < len(active)):
305340
return active
306341
removed = active.pop(idx)
307342
if idx == 0:
308343
removed.end_current_stint(now)
309344
removed.deleted_at = now
310-
history.append(removed)
345+
_save_history(_load_history() + [removed])
311346
if active and idx == 0:
312347
active[0].mark_current(now)
313-
return _commit(active, history)
348+
return _commit(active)
314349

315350

316351
def format_timestamp(dt: datetime | None, now: datetime | None = None) -> str:

0 commit comments

Comments
 (0)