@@ -65,37 +65,219 @@ def config(ctx: typer.Context):
6565 typer .echo ("No changes detected." )
6666
6767@cli .command ()
68- def compile (ctx : typer .Context , date : str = typer .Argument (None )):
68+ def pull (ctx : typer .Context , remote : str = typer .Argument (None , help = "Remote to pull from (omit to pull from all)" )):
6969 """
70- cli: faff compile
71- Compile the timesheet for a given date, defaulting to today.
70+ Pull plans from a remote (or all remotes).
7271 """
7372 try :
74- ws = ctx .obj
75- resolved_date = ws .parse_natural_date ( date )
73+ ws : Workspace = ctx .obj
74+ remotes = ws .plans . remotes ( )
7675
77- log = ws .logs .get_log_or_create (resolved_date )
76+ if remote :
77+ remotes = [r for r in remotes if r .id == remote ]
78+ if len (remotes ) == 0 :
79+ typer .echo (f"Unknown remote: { remote } " , err = True )
80+ raise typer .Exit (1 )
81+
82+ for r in remotes :
83+ try :
84+ plan = r .pull_plan (ws .today ())
85+ if plan :
86+ ws .plans .write_plan (plan )
87+ typer .echo (f"Pulled plan from { r .id } " )
88+ else :
89+ typer .echo (f"No plans found for { r .id } " )
90+ except Exception as e :
91+ typer .echo (f"Error pulling plan from { r .id } : { e } " , err = True )
92+ except Exception as e :
93+ typer .echo (f"Error pulling plans: { e } " , err = True )
94+ raise typer .Exit (1 )
95+
96+ @cli .command ()
97+ def compile (ctx : typer .Context ,
98+ date : str = typer .Argument (None , help = "Specific date to compile (omit to compile all uncompiled logs)" ),
99+ audience : str = typer .Option (None , "--audience" , "-a" , help = "Specific audience to compile for (omit for all)" )):
100+ """
101+ Compile timesheets. By default, compiles all logs that don't have timesheets yet.
102+ Specify a date to force recompile a specific date.
103+ """
104+ try :
105+ ws = ctx .obj
78106 audiences = ws .timesheets .audiences ()
79107
80- for audience in audiences :
81- compiled_timesheet = audience .compile_time_sheet (log )
82- ws .timesheets .write_timesheet (compiled_timesheet )
83- typer .echo (f"Compiled timesheet for { resolved_date } using { audience .id } ." )
108+ if audience :
109+ audiences = [a for a in audiences if a .id == audience ]
110+ if len (audiences ) == 0 :
111+ typer .echo (f"Unknown audience: { audience } " , err = True )
112+ raise typer .Exit (1 )
113+
114+ if date :
115+ # Specific date provided - compile that date
116+ resolved_date = ws .parse_natural_date (date )
117+ log = ws .logs .get_log_or_create (resolved_date )
118+
119+ # FIXME: This check should be in faff-core, not faff-cli
120+ # The compile_time_sheet method should refuse to compile logs with active sessions
121+ # Check for unclosed session
122+ if log .active_session ():
123+ typer .echo (f"Cannot compile { resolved_date } : log has an unclosed session. Run 'faff stop' first." , err = True )
124+ raise typer .Exit (1 )
125+
126+ for aud in audiences :
127+ compiled_timesheet = aud .compile_time_sheet (log )
128+ if compiled_timesheet :
129+ # Sign the timesheet if signing_ids are configured
130+ signing_ids = aud .config .get ('signing_ids' , [])
131+ if signing_ids :
132+ signed = False
133+ for signing_id in signing_ids :
134+ key = ws .identities .get_identity (signing_id )
135+ if key :
136+ compiled_timesheet = compiled_timesheet .sign (signing_id , bytes (key ))
137+ signed = True
138+ else :
139+ typer .echo (f"Warning: No identity key found for { signing_id } " , err = True )
140+
141+ if signed :
142+ ws .timesheets .write_timesheet (compiled_timesheet )
143+ typer .echo (f"Compiled and signed timesheet for { resolved_date } using { aud .id } ." )
144+ else :
145+ ws .timesheets .write_timesheet (compiled_timesheet )
146+ typer .echo (f"Warning: Compiled unsigned timesheet for { resolved_date } using { aud .id } (no valid signing keys)" , err = True )
147+ else :
148+ ws .timesheets .write_timesheet (compiled_timesheet )
149+ typer .echo (f"Warning: Compiled unsigned timesheet for { resolved_date } using { aud .id } (no signing_ids configured)" , err = True )
150+ else :
151+ typer .echo (f"No timesheet for { resolved_date } from { aud .id } (no relevant sessions)." )
152+ else :
153+ # No date provided - find all logs that need compiling
154+ log_dates = ws .logs .list_log_dates ()
155+ existing_timesheets = ws .timesheets .list ()
156+
157+ # Build a set of (audience_id, date) tuples for existing timesheets
158+ existing = {(ts .meta .audience_id , ts .date ) for ts in existing_timesheets }
159+
160+ compiled_count = 0
161+ skipped_unclosed = []
162+ for log_date in log_dates :
163+ log = ws .logs .get_log (log_date )
164+ if not log :
165+ continue
166+
167+ # FIXME: This check should be in faff-core, not faff-cli
168+ # The compile_time_sheet method should refuse to compile logs with active sessions
169+ # Check for unclosed session
170+ if log .active_session ():
171+ skipped_unclosed .append (log_date )
172+ continue
173+
174+ for aud in audiences :
175+ if (aud .id , log_date ) not in existing :
176+ compiled_timesheet = aud .compile_time_sheet (log )
177+ # Sign the timesheet if signing_ids are configured (even if empty)
178+ is_empty = len (compiled_timesheet .timeline ) == 0
179+ signing_ids = aud .config .get ('signing_ids' , [])
180+
181+ if signing_ids :
182+ signed = False
183+ for signing_id in signing_ids :
184+ key = ws .identities .get_identity (signing_id )
185+ if key :
186+ compiled_timesheet = compiled_timesheet .sign (signing_id , bytes (key ))
187+ signed = True
188+ else :
189+ typer .echo (f"Warning: No identity key found for { signing_id } " , err = True )
190+
191+ if signed :
192+ ws .timesheets .write_timesheet (compiled_timesheet )
193+ if is_empty :
194+ typer .echo (f"Compiled and signed empty timesheet for { log_date } using { aud .id } (no relevant sessions)." )
195+ else :
196+ typer .echo (f"Compiled and signed timesheet for { log_date } using { aud .id } ." )
197+ else :
198+ ws .timesheets .write_timesheet (compiled_timesheet )
199+ if is_empty :
200+ typer .echo (f"Warning: Compiled unsigned empty timesheet for { log_date } using { aud .id } (no valid signing keys)" , err = True )
201+ else :
202+ typer .echo (f"Warning: Compiled unsigned timesheet for { log_date } using { aud .id } (no valid signing keys)" , err = True )
203+ else :
204+ ws .timesheets .write_timesheet (compiled_timesheet )
205+ if is_empty :
206+ typer .echo (f"Warning: Compiled unsigned empty timesheet for { log_date } using { aud .id } (no signing_ids configured)" , err = True )
207+ else :
208+ typer .echo (f"Warning: Compiled unsigned timesheet for { log_date } using { aud .id } (no signing_ids configured)" , err = True )
209+
210+ compiled_count += 1
211+
212+ if skipped_unclosed :
213+ typer .echo (f"\n Skipped { len (skipped_unclosed )} log(s) with unclosed sessions:" , err = True )
214+ for log_date in skipped_unclosed :
215+ typer .echo (f" - { log_date } (run 'faff stop' to close the active session)" , err = True )
216+
217+ if compiled_count == 0 and not skipped_unclosed :
218+ typer .echo ("All logs already have compiled timesheets." )
84219 except Exception as e :
85220 typer .echo (f"Error compiling timesheet: { e } " , err = True )
86221 raise typer .Exit (1 )
87222
223+ @cli .command ()
224+ def push (ctx : typer .Context ,
225+ date : str = typer .Argument (None , help = "Specific date to push (omit to push all unsubmitted timesheets)" ),
226+ audience : str = typer .Option (None , "--audience" , "-a" , help = "Specific audience to push to (omit for all)" )):
227+ """
228+ Push timesheets. By default, pushes all compiled timesheets that haven't been submitted yet.
229+ Specify a date to force push a specific date.
230+ """
231+ try :
232+ ws : Workspace = ctx .obj
233+
234+ if date :
235+ # Specific date provided - push that date
236+ resolved_date = ws .parse_natural_date (date )
237+ audiences = ws .timesheets .audiences ()
238+
239+ if audience :
240+ audiences = [a for a in audiences if a .id == audience ]
241+ if len (audiences ) == 0 :
242+ typer .echo (f"Unknown audience: { audience } " , err = True )
243+ raise typer .Exit (1 )
244+
245+ for aud in audiences :
246+ timesheet = ws .timesheets .get_timesheet (aud .id , resolved_date )
247+ if timesheet :
248+ ws .timesheets .submit (timesheet )
249+ typer .echo (f"Pushed timesheet for { resolved_date } to { aud .id } ." )
250+ else :
251+ typer .echo (f"No timesheet found for { aud .id } on { resolved_date } . Did you run 'faff compile' first?" , err = True )
252+ else :
253+ # No date provided - push all unsubmitted timesheets
254+ all_timesheets = ws .timesheets .list ()
255+ unsubmitted = [ts for ts in all_timesheets if ts .meta .submitted_at is None ]
256+
257+ if audience :
258+ unsubmitted = [ts for ts in unsubmitted if ts .meta .audience_id == audience ]
259+
260+ if len (unsubmitted ) == 0 :
261+ typer .echo ("All timesheets have been submitted." )
262+ else :
263+ for timesheet in unsubmitted :
264+ ws .timesheets .submit (timesheet )
265+ typer .echo (f"Pushed timesheet for { timesheet .date } to { timesheet .meta .audience_id } ." )
266+ except Exception as e :
267+ typer .echo (f"Error pushing timesheet: { e } " , err = True )
268+ raise typer .Exit (1 )
269+
88270@cli .command ()
89271def status (ctx : typer .Context ):
90272 """
91- cli: faff status
92- Show the status of the faff repository.
273+ Show the status of the faff repository, including what needs compiling/pushing.
93274 """
94275 try :
95276 ws : Workspace = ctx .obj
96277 typer .echo (f"Status for faff repo root at: { ws .storage ().root_dir ()} " )
97- typer .echo (f"faff-core library version: { faff_core .version ()} " )
278+ typer .echo (f"faff-core library version: { faff_core .version ()} \n " )
98279
280+ # Today's status
99281 log = ws .logs .get_log_or_create (ws .today ())
100282 typer .echo (f"Total recorded time for today: { humanize .precisedelta (log .total_recorded_time (),minimum_unit = 'minutes' )} " )
101283
@@ -108,6 +290,71 @@ def status(ctx: typer.Context):
108290 typer .echo (f"Working on { active_session .intent .alias } for { humanize .precisedelta (duration )} " )
109291 else :
110292 typer .echo ("Not currently working on anything." )
293+
294+ # Check what needs compiling
295+ typer .echo ("\n --- Logs needing compilation ---" )
296+ log_dates = ws .logs .list_log_dates ()
297+ existing_timesheets = ws .timesheets .list ()
298+ audiences = ws .timesheets .audiences ()
299+
300+ # Build a set of (audience_id, date) tuples for existing timesheets
301+ existing = {(ts .meta .audience_id , ts .date ) for ts in existing_timesheets }
302+
303+ needs_compiling = []
304+ has_unclosed = []
305+ for log_date in log_dates :
306+ log = ws .logs .get_log (log_date )
307+ if not log :
308+ continue
309+
310+ # Check if this log needs compiling for any audience
311+ needs_compile_for_audiences = [aud .id for aud in audiences if (aud .id , log_date ) not in existing ]
312+
313+ if needs_compile_for_audiences :
314+ total_hours = log .total_recorded_time ().total_seconds () / 3600
315+ if log .active_session ():
316+ has_unclosed .append ((log_date , total_hours , needs_compile_for_audiences ))
317+ else :
318+ needs_compiling .append ((log_date , total_hours , needs_compile_for_audiences ))
319+
320+ if has_unclosed :
321+ typer .echo ("⚠️ Logs with unclosed sessions (cannot compile):" )
322+ for log_date , hours , audience_ids in has_unclosed :
323+ typer .echo (f" { log_date } : { hours :.2f} h (for { ', ' .join (audience_ids )} )" )
324+ typer .echo (" Run 'faff stop' to close the active session\n " )
325+
326+ if needs_compiling :
327+ typer .echo ("Ready to compile:" )
328+ for log_date , hours , audience_ids in needs_compiling :
329+ typer .echo (f" { log_date } : { hours :.2f} h (for { ', ' .join (audience_ids )} )" )
330+ total_to_compile = sum (hours for _ , hours , _ in needs_compiling )
331+ typer .echo (f" Total: { len (needs_compiling )} log(s), { total_to_compile :.2f} h" )
332+ elif not has_unclosed :
333+ typer .echo ("All logs have compiled timesheets ✓" )
334+
335+ # Check what needs pushing
336+ typer .echo ("\n --- Timesheets needing submission ---" )
337+ unsubmitted = [ts for ts in existing_timesheets if ts .meta .submitted_at is None ]
338+
339+ if unsubmitted :
340+ # Group by audience
341+ by_audience = {}
342+ for ts in unsubmitted :
343+ if ts .meta .audience_id not in by_audience :
344+ by_audience [ts .meta .audience_id ] = []
345+ by_audience [ts .meta .audience_id ].append (ts )
346+
347+ for audience_id , timesheets in by_audience .items ():
348+ typer .echo (f"For { audience_id } :" )
349+ total_hours = 0
350+ for ts in sorted (timesheets , key = lambda t : t .date ):
351+ hours = sum (s .duration .total_seconds () for s in ts .timeline ) / 3600
352+ total_hours += hours
353+ typer .echo (f" { ts .date } : { hours :.2f} h" )
354+ typer .echo (f" Total: { len (timesheets )} timesheet(s), { total_hours :.2f} h" )
355+ else :
356+ typer .echo ("All timesheets have been submitted ✓" )
357+
111358 except Exception as e :
112359 typer .echo (f"Error getting status: { e } " , err = True )
113360 raise typer .Exit (1 )
0 commit comments