Skip to content

Commit 14338de

Browse files
eerovilclaude
andcommitted
Add streaming fill, double-click Opus entry, project/title suggestions, hours counters
- Streaming week-fill: per-day ndjson stream with a truthful progress bar and a Cancel that keeps already-finished days (per-day persist). - Double-click an empty slot to grow a single billing entry from the click via Opus, bounded by neighbouring entries; optimistic placeholder while it runs. - EntrySuggestion model + /suggestions endpoint: Fill/double-click precompute ranked project + title hints per entry, surfaced instantly in the editor (projects pinned atop the dropdown, titles as one-click chips). - Timeline: snap drag/resize to 30-min increments with a 30-min minimum. - Per-day and weekly billed-hours counters. - Toggl sync: on update of an entry deleted remotely (404), recreate it and adopt the new toggl_id instead of failing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5922976 commit 14338de

10 files changed

Lines changed: 696 additions & 77 deletions

File tree

tracklater/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ def create_app(name=__name__):
3333
app.config['SQLALCHEMY_DATABASE_URI'] = _database_uri()
3434
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
3535

36-
from tracklater.models import ApiCall, Project, Issue, Entry, SyncJob # noqa
36+
from tracklater.models import ( # noqa
37+
ApiCall, Project, Issue, Entry, SyncJob, EntrySuggestion,
38+
)
3739

3840
db.init_app(app)
3941
with app.app_context():

tracklater/ai_local_claude.py

Lines changed: 239 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
ZoneInfo = None # type: ignore
3030

3131
from tracklater import settings
32-
from tracklater.models import Entry
32+
from tracklater.database import db
33+
from tracklater.models import Entry, EntrySuggestion
3334
from tracklater.ai_local import (
3435
MODULE_NAME,
3536
_allowed_local_projects,
@@ -245,8 +246,14 @@ def build_day_prompt(
245246
bridged billable hours with the weekday-daytime / evening / weekend rules, and
246247
blocks snapped to :00/:30.
247248
249+
For EACH entry also provide ranked alternatives the user might pick instead when
250+
editing: `project_options` (2-4 plausible `group:Project` for this block, BEST
251+
first — the first MUST equal `project`) and `title_options` (2-4 plausible titles,
252+
BEST first — the first MUST equal `title`). These are hints for a dropdown.
253+
248254
Output ONLY a JSON array, no prose, no code fence. Each item:
249-
{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"..."}}
255+
{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"...",
256+
"project_options":["group:Project", ...],"title_options":["...", ...]}}
250257
Times are LOCAL 24h. Entries must not overlap. If the day is unbillable (leave /
251258
no work), output an empty array []."""
252259

@@ -313,16 +320,50 @@ def parse_entries(
313320
e += timedelta(days=1) # crossed midnight
314321
# Model reasons in local time; persist UTC to match every other module.
315322
s, e = _to_utc(s), _to_utc(e)
323+
title = str(item.get('title') or 'Work')[:255]
324+
# Ranked editor hints: keep only allowed projects, ensure the chosen one
325+
# leads, dedupe, cap at 4. Fall back to the single chosen value.
326+
popts = [p for p in (item.get('project_options') or []) if p in allowed_projects]
327+
popts = [project] + [p for p in popts if p != project]
328+
topts = [str(t)[:255] for t in (item.get('title_options') or []) if t]
329+
topts = [title] + [t for t in topts if t != title]
316330
entries.append({
317331
'start_time': s,
318332
'end_time': e,
319-
'title': str(item.get('title') or 'Work')[:255],
333+
'title': title,
320334
'project': project,
335+
'project_options': list(dict.fromkeys(popts))[:4],
336+
'title_options': list(dict.fromkeys(topts))[:4],
321337
})
322338
entries.sort(key=lambda x: x['start_time'])
323339
return entries
324340

325341

342+
def _write_suggestions(
343+
entries_data: List[Dict[str, Any]],
344+
win_start: Optional[datetime] = None,
345+
win_end: Optional[datetime] = None,
346+
replace: bool = True,
347+
) -> None:
348+
"""Persist per-entry project/title hints (EntrySuggestion rows). When replace
349+
and a window is given, clear existing hints starting in [win_start, win_end]
350+
first so a re-fill doesn't accumulate stale rows."""
351+
if replace and win_start is not None and win_end is not None:
352+
EntrySuggestion.query.filter(
353+
EntrySuggestion.start_time >= win_start,
354+
EntrySuggestion.start_time <= win_end,
355+
).delete()
356+
for item in entries_data:
357+
db.session.add(EntrySuggestion(
358+
start_time=item['start_time'],
359+
end_time=item['end_time'],
360+
date_group=item['start_time'].strftime('%Y-%m-%d'),
361+
projects=item.get('project_options') or [item['project']],
362+
titles=item.get('title_options') or [item['title']],
363+
))
364+
db.session.commit()
365+
366+
326367
def populate_local_entries_ai(
327368
start_date: datetime,
328369
end_date: datetime,
@@ -372,6 +413,200 @@ def populate_local_entries_ai(
372413
)
373414
if errors:
374415
logger.warning("Some days failed and were skipped: %s", "; ".join(errors))
375-
return persist_local_entries(
416+
created = persist_local_entries(
376417
entries_data, start_date, end_date, replace_existing=replace_existing,
377418
)
419+
_write_suggestions(entries_data, start_date, end_date, replace=replace_existing)
420+
return created
421+
422+
423+
# --------------------------------------------------------------------------- #
424+
# Feature 2: streaming week-fill (per-day, cancellable, keeps finished days)
425+
# --------------------------------------------------------------------------- #
426+
def _local_day_utc_bounds(day: str) -> (datetime, datetime):
427+
"""UTC [start, end) covering one LOCAL calendar day, for scoping the
428+
per-day draft-delete in persist_local_entries to just that day."""
429+
local_start = datetime.strptime(day, '%Y-%m-%d')
430+
local_end = local_start + timedelta(days=1)
431+
return _to_utc(local_start), _to_utc(local_end)
432+
433+
434+
def stream_populate_local_entries_ai(
435+
start_date: datetime,
436+
end_date: datetime,
437+
replace_existing: bool = True,
438+
):
439+
"""Generator variant of populate_local_entries_ai for streaming.
440+
441+
Yields progress dicts as each day completes and persists that day's entries
442+
immediately (per-day), so a cancellation (the consumer stops iterating) keeps
443+
the days already finished and leaves untouched days alone. Each yielded dict:
444+
{'type': 'progress', 'day', 'done', 'total', 'count'}
445+
{'type': 'day_error', 'day', 'error'}
446+
{'type': 'done', 'count', 'errors'}
447+
{'type': 'error', 'error'} (fatal, before any day ran)
448+
"""
449+
if MODULE_NAME not in settings.ENABLED_MODULES:
450+
yield {'type': 'error', 'error': 'local module is not enabled'}
451+
return
452+
if not _has_source_data(start_date, end_date):
453+
yield {'type': 'error', 'error':
454+
'No source timemodule entries in this range. '
455+
'Fetch activitywatch/git/etc. first.'}
456+
return
457+
allowed_projects = _allowed_local_projects(start_date, end_date)
458+
if not allowed_projects:
459+
yield {'type': 'error', 'error': 'No LOCAL projects configured'}
460+
return
461+
digests = gather_signal(start_date, end_date)
462+
if not digests:
463+
yield {'type': 'error', 'error':
464+
'No git/ActivityWatch signal in this range to reconstruct from.'}
465+
return
466+
467+
days = sorted(digests)
468+
total = len(days)
469+
prior_titles: List[str] = []
470+
errors: List[str] = []
471+
total_count = 0
472+
for idx, day in enumerate(days):
473+
prompt = build_day_prompt(day, digests[day], allowed_projects, prior_titles)
474+
logger.info("Streaming claude (%s) for local entries %s", _model(), day)
475+
try:
476+
raw = run_claude(prompt)
477+
day_entries = parse_entries(raw, allowed_projects)
478+
except ValueError as exc:
479+
logger.warning("claude failed for %s: %s", day, exc)
480+
errors.append(f"{day}: {exc}")
481+
yield {'type': 'day_error', 'day': day, 'error': str(exc)}
482+
continue
483+
# Persist THIS day now, scoping the draft-delete to the local day so a
484+
# later cancel can't roll it back and untouched days keep their entries.
485+
d_start, d_end = _local_day_utc_bounds(day)
486+
try:
487+
if day_entries:
488+
persist_local_entries(
489+
day_entries, d_start, d_end, replace_existing=replace_existing,
490+
)
491+
_write_suggestions(day_entries, d_start, d_end, replace=replace_existing)
492+
total_count += len(day_entries)
493+
except ValueError as exc:
494+
logger.warning("persist failed for %s: %s", day, exc)
495+
errors.append(f"{day}: {exc}")
496+
yield {'type': 'day_error', 'day': day, 'error': str(exc)}
497+
continue
498+
for e in day_entries:
499+
if e['title'] not in prior_titles:
500+
prior_titles.append(e['title'])
501+
yield {'type': 'progress', 'day': day, 'done': idx + 1,
502+
'total': total, 'count': len(day_entries)}
503+
yield {'type': 'done', 'count': total_count, 'errors': errors}
504+
505+
506+
# --------------------------------------------------------------------------- #
507+
# Feature 1: single entry from a double-click (grow a block from the click seed)
508+
# --------------------------------------------------------------------------- #
509+
def build_click_prompt(
510+
day: str, day_signal: str, allowed_projects: set, prior_titles: List[str],
511+
click_local: datetime, gap_lo_local: Optional[datetime],
512+
gap_hi_local: Optional[datetime],
513+
) -> str:
514+
"""Prompt Claude for ONE entry grown outward from the click point."""
515+
guidebook = _read_guidebook()
516+
projects = '\n'.join(f' - {p}' for p in sorted(allowed_projects))
517+
carry = (
518+
'\nEpic titles already in use this week (reuse the matching one to carry an '
519+
'epic forward, per the guidebook):\n ' + '\n '.join(prior_titles)
520+
if prior_titles else ''
521+
)
522+
bounds = ''
523+
if gap_lo_local is not None:
524+
bounds += f"\n- The entry MUST NOT start before {gap_lo_local.strftime('%H:%M')} (previous entry)."
525+
if gap_hi_local is not None:
526+
bounds += f"\n- The entry MUST NOT end after {gap_hi_local.strftime('%H:%M')} (next entry)."
527+
return f"""You reconstruct ONE manual billing time entry from git + ActivityWatch data.
528+
Follow the guidebook below EXACTLY for client/project selection, titles and the
529+
billable-hours model.
530+
531+
================ GUIDEBOOK ================
532+
{guidebook}
533+
================ END GUIDEBOOK ================
534+
535+
Allowed projects (use these exact `group:Project` strings, nothing else):
536+
{projects}
537+
{carry}
538+
539+
Source signal for {day} (LOCAL time already):
540+
{day_signal}
541+
542+
TASK: the user double-clicked the timeline at {click_local.strftime('%H:%M')} to create
543+
a SINGLE entry there. Build exactly ONE entry:
544+
1. SEED: within ±30 min of {click_local.strftime('%H:%M')}, find the single dominant
545+
project/component (its commits + ActivityWatch activity). That is this entry's
546+
project. Fold sub-projects into their parent per the guidebook.
547+
2. GROW: extend the block earlier and later from the click. Keep extending each side
548+
while the SAME project/component's commits/activity continue. STOP a side as soon
549+
as the focus clearly switches to a different project/component, or at a real AFK
550+
gap.{bounds}
551+
3. Snap start/end to :00/:30. Title per the guidebook (branch slug first).
552+
4. If there is NO meaningful signal within ±30 min of the click, output an empty
553+
array [] (the UI will create a blank entry to fill in by hand).
554+
555+
Also provide ranked alternatives for the editor dropdown: `project_options` (2-4
556+
plausible `group:Project`, BEST first — first MUST equal `project`) and
557+
`title_options` (2-4 plausible titles, BEST first — first MUST equal `title`).
558+
559+
Output ONLY a JSON array with AT MOST ONE item, no prose, no code fence:
560+
[{{"date":"{day}","start":"HH:MM","end":"HH:MM","project":"group:Project","title":"...",
561+
"project_options":["group:Project", ...],"title_options":["...", ...]}}]
562+
Times are LOCAL 24h."""
563+
564+
565+
def populate_entry_at(
566+
click: datetime,
567+
prev_end: Optional[datetime] = None,
568+
next_start: Optional[datetime] = None,
569+
) -> List[Entry]:
570+
"""Create a single local entry grown from a double-click at `click` (naive UTC).
571+
572+
`prev_end`/`next_start` (naive UTC) bound the free gap so the result can't
573+
overlap neighbours. Returns the created entries (0 or 1); an empty list means
574+
'no signal near the click' — the caller drops a blank manual entry instead.
575+
"""
576+
if MODULE_NAME not in settings.ENABLED_MODULES:
577+
raise ValueError("local module is not enabled")
578+
click_local = _to_local(click)
579+
day = click_local.strftime('%Y-%m-%d')
580+
day_start = datetime.strptime(day, '%Y-%m-%d')
581+
day_end = day_start + timedelta(days=1)
582+
# _to_utc the local day bounds to gather that whole local day's signal.
583+
digests = gather_signal(_to_utc(day_start), _to_utc(day_end) - timedelta(seconds=1))
584+
if day not in digests:
585+
return [] # no signal that day at all -> blank manual entry
586+
allowed_projects = _allowed_local_projects(_to_utc(day_start), _to_utc(day_end))
587+
if not allowed_projects:
588+
raise ValueError("No LOCAL projects configured")
589+
prompt = build_click_prompt(
590+
day, digests[day], allowed_projects, [], click_local,
591+
_to_local(prev_end) if prev_end else None,
592+
_to_local(next_start) if next_start else None,
593+
)
594+
logger.info("Calling claude (%s) for click entry at %s", _model(), click_local)
595+
raw = run_claude(prompt)
596+
entries_data = parse_entries(raw, allowed_projects)
597+
if not entries_data:
598+
return []
599+
entries_data = entries_data[:1] # exactly one entry from a click
600+
# Hard clamp to the free gap so a stray model end can never overlap neighbours.
601+
e = entries_data[0]
602+
if prev_end is not None and e['start_time'] < prev_end:
603+
e['start_time'] = prev_end
604+
if next_start is not None and e['end_time'] > next_start:
605+
e['end_time'] = next_start
606+
if e['end_time'] <= e['start_time']:
607+
return []
608+
created = persist_local_entries(
609+
entries_data, e['start_time'], e['end_time'], replace_existing=False,
610+
)
611+
_write_suggestions(entries_data, e['start_time'], e['end_time'], replace=True)
612+
return created

tracklater/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,32 @@ def to_dict(self):
107107
}
108108

109109

110+
class EntrySuggestion(db.Model):
111+
"""Opus-precomputed project/title hints for a time window, produced during the
112+
Fill (and double-click) so the editor can offer ranked picks with no live call.
113+
Non-authoritative: matched to an entry by time-window overlap and never
114+
auto-applied. Left stale if the entry is later moved (it's only a hint)."""
115+
__tablename__ = 'entry_suggestions'
116+
pk: int = Column(Integer, primary_key=True)
117+
start_time: datetime = Column(DateTime, nullable=False) # UTC, like every module
118+
end_time: Optional[datetime] = Column(DateTime)
119+
date_group: Optional[str] = Column(String(50))
120+
# Ranked lists; project strings are Project.pid ("group:Project"), the exact
121+
# value the editor dropdown binds to, so no resolution is needed client-side.
122+
projects: list = Column(PickleType)
123+
titles: list = Column(PickleType)
124+
created = Column(DateTime, default=datetime.utcnow)
125+
126+
def to_dict(self):
127+
return {
128+
"start_time": self.start_time,
129+
"end_time": self.end_time,
130+
"date_group": self.date_group,
131+
"projects": list(self.projects or []),
132+
"titles": list(self.titles or []),
133+
}
134+
135+
110136
class SyncJob(db.Model):
111137
"""
112138
A pending push of a toggl-module entry to the Toggl API. The background

0 commit comments

Comments
 (0)