99
1010STACK_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
185221def 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
190226def 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
196231def 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
203238def 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
214249def 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
231266def 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
242277def 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
256291def 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
272307def 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
292327def 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
301336def 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
316351def format_timestamp (dt : datetime | None , now : datetime | None = None ) -> str :
0 commit comments