diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index 3392b540..2770c044 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -705,17 +705,23 @@ If `--at` option is given, the provided stopping time is used. The specified time must be after the beginning of the to-be-ended frame and must not be in the future. -Example: +You can optionally pass a log message to be saved with the frame via +the ``-n/--note`` option. +Example: $ watson stop --at 13:37 Stopping project apollo11, started an hour ago and stopped 30 minutes ago. (id: e9ccd52) # noqa: E501 + $ watson stop -n "Done some thinking" + Stopping project apollo11, started a minute ago. (id: e7ccd52) + Log message: Done some thinking ### Options Flag | Help -----|----- `--at DATETIME` | Stop frame at this time. Must be in (YYYY-MM-DDT)?HH:MM(:SS)? format. +`-n, --note TEXT` | Save given log message with the project frame. `--help` | Show this message and exit. ## `sync` diff --git a/tests/test_utils.py b/tests/test_utils.py index 14612846..316d87fa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -300,7 +300,7 @@ def test_frames_to_csv(watson): result = frames_to_csv(watson.frames) read_csv = list(csv.reader(StringIO(result))) - header = ['id', 'start', 'stop', 'project', 'tags'] + header = ['id', 'start', 'stop', 'project', 'tags', 'note'] assert len(read_csv) == 2 assert read_csv[0] == header assert read_csv[1][3] == 'foo' @@ -319,7 +319,7 @@ def test_frames_to_json(watson): result = json.loads(frames_to_json(watson.frames)) - keys = {'id', 'start', 'stop', 'project', 'tags'} + keys = {'id', 'start', 'stop', 'project', 'tags', 'note'} assert len(result) == 1 assert set(result[0].keys()) == keys assert result[0]['project'] == 'foo' diff --git a/tests/test_watson.py b/tests/test_watson.py index c70a1522..3fc4cb97 100644 --- a/tests/test_watson.py +++ b/tests/test_watson.py @@ -143,6 +143,54 @@ def test_frames_without_tags(mocker, watson): assert watson.frames[0].tags == [] +def test_frames_with_note(mocker, watson): + """Test loading frames with notes.""" + content = json.dumps([ + [3601, 3610, 'foo', 'abcdefg', ['A', 'B', 'C'], 3650, + "My hovercraft is full of eels"] + ]) + + mocker.patch('%s.open' % builtins, mocker.mock_open(read_data=content)) + assert len(watson.frames) == 1 + frame = watson.frames['abcdefg'] + assert frame.id == 'abcdefg' + assert frame.project == 'foo' + assert frame.start == arrow.get(3601) + assert frame.stop == arrow.get(3610) + assert frame.tags == ['A', 'B', 'C'] + assert frame.note == "My hovercraft is full of eels" + + +def test_frames_without_note(mocker, watson): + """Test loading frames without notes.""" + content = json.dumps([ + [3601, 3610, 'foo', 'abcdefg'], + [3611, 3620, 'foo', 'hijklmn', ['A', 'B', 'C']], + [3621, 3630, 'foo', 'opqrstu', ['A', 'B', 'C'], 3630] + ]) + + mocker.patch('%s.open' % builtins, mocker.mock_open(read_data=content)) + assert len(watson.frames) == 3 + frame = watson.frames['abcdefg'] + assert frame.id == 'abcdefg' + assert frame.project == 'foo' + assert frame.start == arrow.get(3601) + assert frame.stop == arrow.get(3610) + assert frame.tags == [] + assert frame.note is None + + frame = watson.frames['hijklmn'] + assert frame.id == 'hijklmn' + assert frame.tags == ['A', 'B', 'C'] + assert frame.note is None + + frame = watson.frames['opqrstu'] + assert frame.id == 'opqrstu' + assert frame.tags == ['A', 'B', 'C'] + assert frame.updated_at == arrow.get(3630) + assert frame.note is None + + def test_frames_with_empty_file(mocker, watson): mocker.patch('builtins.open', mocker.mock_open(read_data="")) mocker.patch('os.path.getsize', return_value=0) @@ -315,6 +363,32 @@ def test_stop_started_project_without_tags(watson): assert watson.frames[0].tags == [] +def test_stop_started_project_without_note(watson): + """Test stopping watson without adding a note.""" + watson.start('foo') + watson.stop() + + assert watson.current == {} + assert watson.is_started is False + assert len(watson.frames) == 1 + frame = watson.frames[0] + assert frame.project == 'foo' + assert frame.note is None + + +def test_stop_started_project_with_note(watson): + """Test stopping watson when adding a note.""" + watson.start('foo') + watson.stop(None, "My hovercraft is full of eels") + + assert watson.current == {} + assert watson.is_started is False + assert len(watson.frames) == 1 + frame = watson.frames[0] + assert frame.project == 'foo' + assert frame.note == "My hovercraft is full of eels" + + def test_stop_no_project(watson): with pytest.raises(WatsonError): watson.stop() @@ -403,7 +477,8 @@ def test_save_empty_current(config_dir, mocker, json_mock): assert json_mock.call_count == 1 result = json_mock.call_args[0][0] - assert result == {'project': 'foo', 'start': 4000, 'tags': []} + assert result == {'project': 'foo', 'start': 4000, + 'tags': [], 'note': None} watson.current = {} watson.save() @@ -763,9 +838,12 @@ def test_report(watson): assert 'time' in report['projects'][0]['tags'][0] assert report['projects'][0]['tags'][1]['name'] == 'B' assert 'time' in report['projects'][0]['tags'][1] + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][1]['notes']) == 0 watson.start('bar', tags=['C']) - watson.stop() + watson.stop(note='bar note') report = watson.report(arrow.now(), arrow.now()) assert len(report['projects']) == 2 @@ -774,6 +852,13 @@ def test_report(watson): assert len(report['projects'][0]['tags']) == 1 assert report['projects'][0]['tags'][0]['name'] == 'C' + assert len(report['projects'][1]['notes']) == 0 + assert len(report['projects'][1]['tags'][0]['notes']) == 0 + assert len(report['projects'][1]['tags'][1]['notes']) == 0 + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert report['projects'][0]['tags'][0]['notes'][0] == 'bar note' + report = watson.report( arrow.now(), arrow.now(), projects=['foo'], tags=['B'] ) @@ -783,16 +868,36 @@ def test_report(watson): assert report['projects'][0]['tags'][0]['name'] == 'B' watson.start('baz', tags=['D']) - watson.stop() + watson.stop(note='baz note') + + watson.start('foo') + watson.stop(note='foo no tags') + + watson.start('foo', tags=['A']) + watson.stop(note='foo one tag A') report = watson.report(arrow.now(), arrow.now(), projects=["foo"]) + assert len(report['projects']) == 1 + assert len(report['projects'][0]['notes']) == 1 + # A project-level note because this frame has no tags + assert report['projects'][0]['notes'][0] == 'foo no tags' + assert len(report['projects'][0]['tags']) == 2 + assert report['projects'][0]['tags'][0]['name'] == 'A' + assert report['projects'][0]['tags'][1]['name'] == 'B' + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert len(report['projects'][0]['tags'][1]['notes']) == 0 + # A tag-level note because this frame has tags + assert report['projects'][0]['tags'][0]['notes'][0] == 'foo one tag A' report = watson.report(arrow.now(), arrow.now(), ignore_projects=["bar"]) assert len(report['projects']) == 2 report = watson.report(arrow.now(), arrow.now(), tags=["A"]) assert len(report['projects']) == 1 + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert report['projects'][0]['tags'][0]['notes'][0] == 'foo one tag A' report = watson.report(arrow.now(), arrow.now(), ignore_tags=["D"]) assert len(report['projects']) == 2 diff --git a/watson/cli.py b/watson/cli.py index 34de207d..465d0775 100644 --- a/watson/cli.py +++ b/watson/cli.py @@ -27,6 +27,7 @@ confirm_tags, create_watson, flatten_report_for_csv, + format_note, format_timedelta, frames_to_csv, frames_to_json, @@ -164,17 +165,21 @@ def help(ctx, command): click.echo(cmd.get_help(ctx)) -def _start(watson, project, tags, restart=False, start_at=None, gap=True): +def _start(watson, project, tags, restart=False, start_at=None, gap=True, + note=None): """ Start project with given list of tags and save status. """ current = watson.start(project, tags, restart=restart, start_at=start_at, - gap=gap,) + gap=gap, note=note) click.echo("Starting project {}{} at {}".format( style('project', project), (" " if current['tags'] else "") + style('tags', current['tags']), style('time', "{:HH:mm}".format(current['start'])) )) + if note: + click.echo(format_note(note)) + watson.save() @@ -193,11 +198,13 @@ def _start(watson, project, tags, restart=False, start_at=None, gap=True): help="Confirm addition of new project.") @click.option('-b', '--confirm-new-tag', is_flag=True, default=False, help="Confirm creation of new tag.") +@click.option('-n', '--note', type=str, default=None, + help="A brief note that describe time entry being started") @click.pass_obj @click.pass_context @catch_watson_error def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, - gap_=True): + gap_=True, note=None): """ Start monitoring time for the given project. You can add tags indicating more specifically what you are working on with @@ -259,16 +266,18 @@ def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, watson.config.getboolean('options', 'stop_on_start')): ctx.invoke(stop) - _start(watson, project, tags, start_at=at_, gap=gap_) + _start(watson, project, tags, start_at=at_, gap=gap_, note=note) @cli.command(context_settings={'ignore_unknown_options': True}) @click.option('--at', 'at_', type=DateTime, default=None, help=('Stop frame at this time. Must be in ' '(YYYY-MM-DDT)?HH:MM(:SS)? format.')) +@click.option('-n', '--note', 'note', default=None, + help="Save given log note with the project frame.") @click.pass_obj @catch_watson_error -def stop(watson, at_): +def stop(watson, at_, note): """ Stop monitoring time for the current project. @@ -276,13 +285,21 @@ def stop(watson, at_): specified time must be after the beginning of the to-be-ended frame and must not be in the future. - Example: + You can optionally pass a log message to be saved with the frame via + the ``-n/--note`` option. + + Examples: \b + + $ watson stop -n "Done some thinking" + Stopping project apollo11, started a minute ago. (id: e7ccd52) + >> Done some thinking + $ watson stop --at 13:37 Stopping project apollo11, started an hour ago and stopped 30 minutes ago. (id: e9ccd52) # noqa: E501 """ - frame = watson.stop(stop_at=at_) + frame = watson.stop(stop_at=at_, note=note) output_str = "Stopping project {}{}, started {} and stopped {}. (id: {})" click.echo(output_str.format( style('project', frame.project), @@ -291,6 +308,10 @@ def stop(watson, at_): style('time', frame.stop.humanize()), style('short_id', frame.id), )) + + if frame.note: + click.echo(format_note(frame.note)) + watson.save() @@ -434,6 +455,12 @@ def status(watson, project, tags, elapsed): style('time', current['start'].strftime(timefmt)) )) + if current['note']: + click.echo(u"{}{}".format( + style('note', '>> '), + style('note', current['note']) + )) + _SHORTCUT_OPTIONS = ['all', 'year', 'month', 'luna', 'week', 'day'] _SHORTCUT_OPTIONS_VALUES = { @@ -508,11 +535,14 @@ def status(watson, project, tags, elapsed): help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n', '--notes', 'show_notes', default=False, is_flag=True, + help="Show frame notes in report.") @click.pass_obj @catch_watson_error def report(watson, current, from_, to, projects, tags, ignore_projects, ignore_tags, year, month, week, day, luna, all, output_format, - pager, aggregated=False, include_partial_frames=True): + pager, aggregated=False, include_partial_frames=True, + show_notes=False): """ Display a report of the time spent on each project. @@ -538,6 +568,10 @@ def report(watson, current, from_, to, projects, tags, ignore_projects, If you are outputting to the terminal, you can selectively enable a pager through the `--pager` option. + You can include frame notes in the report by passing the --notes + option. Messages will always be present in *JSON* reports. Messages are + never included in *CSV* reports. + You can change the output format for the report from *plain text* to *JSON* using the `--json` option or to *CSV* using the `--csv` option. Only one of these two options can be used at once. @@ -592,14 +626,16 @@ def report(watson, current, from_, to, projects, tags, ignore_projects, "tags": [ { "name": "export", - "time": 530.0 + "time": 530.0, + "notes": ["working hard"] }, { "name": "report", "time": 530.0 } ], - "time": 530.0 + "time": 530.0, + "notes": ["fixing bug #74", "refactor tests"] } ], "time": 530.0, @@ -699,6 +735,13 @@ def _final_print(lines): project=style('project', project['name']) )) + if show_notes: + for note in project['notes']: + _print(u'{tab}{note}'.format( + tab=tab, + note=format_note(note), + )) + tags = project['tags'] if tags: longest_tag = max(len(tag) for tag in tags or ['']) @@ -712,6 +755,13 @@ def _final_print(lines): tag['name'], longest_tag )), )) + + if show_notes: + for note in tag['notes']: + _print(u'\t{tab}{note}'.format( + tab=tab, + note=format_note(note), + )) _print("") # if this is a report invoked from `aggregate` return the lines; do not @@ -763,11 +813,13 @@ def _final_print(lines): help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n', '--notes', 'show_notes', default=False, is_flag=True, + help="Show frame notes in report.") @click.pass_obj @click.pass_context @catch_watson_error def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, - pager): + pager, show_notes): """ Display a report of the time spent on each project aggregated by day. @@ -845,9 +897,9 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, from_offset = from_ + offset output = ctx.invoke(report, current=current, from_=from_offset, to=from_offset, projects=projects, tags=tags, - output_format=output_format, - pager=pager, aggregated=True, - include_partial_frames=True) + output_format=output_format, pager=pager, + aggregated=True, include_partial_frames=True, + show_notes=show_notes) if 'json' in output_format: lines.append(output) @@ -939,10 +991,13 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n/-N', '--notes/--no-notes', 'show_notes', default=True, + help="(Don't) output notes.") @click.pass_obj @catch_watson_error def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, - ignore_tags, year, month, week, day, luna, all, output_format, pager): + ignore_tags, year, month, week, day, luna, all, output_format, pager, + show_notes): """ Display each recorded session during the given timespan. @@ -969,6 +1024,9 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, `--json` option or to *CSV* using the `--csv` option. Only one of these two options can be used at once. + You can control whether or not notes for each frame are displayed by + passing --notes or --no-notes. + Example: \b @@ -999,12 +1057,12 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, 1070ddb 13:48 to 16:17 2h 29m 11s voyager1 [antenna, sensors] \b $ watson log --from 2014-04-16 --to 2014-04-17 --csv - id,start,stop,project,tags - a96fcde,2014-04-17 09:15,2014-04-17 09:43,hubble,"lens, camera, transmission" - 5e91316,2014-04-17 10:19,2014-04-17 12:59,hubble,"camera, transmission" - 761dd51,2014-04-17 14:42,2014-04-17 15:54,voyager1,antenna - 02cb269,2014-04-16 09:53,2014-04-16 12:43,apollo11,wheels - 1070ddb,2014-04-16 13:48,2014-04-16 16:17,voyager1,"antenna, sensors" + id,start,stop,project,tags,note + a96fcde,2014-04-17 09:15,2014-04-17 09:43,hubble,"lens, camera, transmission", + 5e91316,2014-04-17 10:19,2014-04-17 12:59,hubble,"camera, transmission", + 761dd51,2014-04-17 14:42,2014-04-17 15:54,voyager1,antenna, + 02cb269,2014-04-16 09:53,2014-04-16 12:43,apollo11,wheels, + 1070ddb,2014-04-16 13:48,2014-04-16 16:17,voyager1,"antenna, sensors", """ # noqa for start_time in (_ for _ in [day, week, month, luna, year, all] if _ is not None): @@ -1027,7 +1085,7 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, watson.config.getboolean('options', 'log_current')): cur = watson.current watson.frames.add(cur['project'], cur['start'], arrow.utcnow(), - cur['tags'], id="current") + cur['tags'], id="current", note=cur['note']) if reverse is None: reverse = watson.config.getboolean('options', 'reverse_log', True) @@ -1265,7 +1323,8 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): id = frame.id elif watson.is_started: frame = Frame(watson.current['start'], None, watson.current['project'], - None, watson.current['tags']) + None, watson.current['tags'], None, + watson.current['note']) elif watson.frames: frame = watson.frames[-1] id = frame.id @@ -1278,6 +1337,7 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): 'start': frame.start.format(datetime_format), 'project': frame.project, 'tags': frame.tags, + 'note': "" if frame.note is None else frame.note, } if id: @@ -1322,6 +1382,7 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): raise ValueError("Start time cannot be in the future") if stop and stop > arrow.utcnow(): raise ValueError("Stop time cannot be in the future") + note = data.get('note') # break out of while loop and continue execution of # the edit function normally break @@ -1342,9 +1403,17 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): # we reach this when we break out of the while loop above if id: - watson.frames[id] = (project, start, stop, tags) + if all((project == frame.project, start == frame.start, + stop == frame.stop, tags == frame.tags, + note == frame.note)): + updated_at = frame.updated_at + else: + updated_at = arrow.utcnow() + + watson.frames[id] = (project, start, stop, tags, id, updated_at, note) else: - watson.current = dict(start=start, project=project, tags=tags) + watson.current = dict(start=start, project=project, tags=tags, + note=note) watson.save() click.echo( @@ -1364,6 +1433,9 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): ) ) + if note is not None and note != '': + click.echo("Message: {}".format(style('note', note))) + @cli.command(context_settings={'ignore_unknown_options': True}) @click.argument('id', autocompletion=get_frames) @@ -1603,7 +1675,8 @@ def merge(watson, frames_with_conflict, force): 'project': original_frame.project, 'start': original_frame.start.format(date_format), 'stop': original_frame.stop.format(date_format), - 'tags': original_frame.tags + 'tags': original_frame.tags, + 'note': original_frame.note } click.echo("frame {}:".format(style('short_id', original_frame.id))) click.echo("{}".format('\n'.join('<' + line for line in json.dumps( @@ -1635,7 +1708,8 @@ def merge(watson, frames_with_conflict, force): 'project': conflict_frame_copy.project, 'start': conflict_frame_copy.start.format(date_format), 'stop': conflict_frame_copy.stop.format(date_format), - 'tags': conflict_frame_copy.tags + 'tags': conflict_frame_copy.tags, + 'note': conflict_frame_copy.note } click.echo("{}".format('\n'.join('>' + line for line in json.dumps( conflict_frame_data, indent=4, ensure_ascii=False).splitlines()))) @@ -1649,10 +1723,9 @@ def merge(watson, frames_with_conflict, force): # merge in any non-conflicting frames for frame in merging: - start, stop, project, id, tags, updated_at = frame.dump() + start, stop, project, id, tags, updated_at, note = frame.dump() original_frames.add(project, start, stop, tags=tags, id=id, - updated_at=updated_at) - + updated_at=updated_at, note=note) watson.frames = original_frames watson.frames.changed = True watson.save() diff --git a/watson/frames.py b/watson/frames.py index a99e3189..aed9ab4f 100644 --- a/watson/frames.py +++ b/watson/frames.py @@ -4,11 +4,12 @@ from collections import namedtuple -HEADERS = ('start', 'stop', 'project', 'id', 'tags', 'updated_at') +HEADERS = ('start', 'stop', 'project', 'id', 'tags', 'updated_at', 'note') class Frame(namedtuple('Frame', HEADERS)): - def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): + def __new__(cls, start, stop, project, id, tags=None, updated_at=None, + note=None): try: if not isinstance(start, arrow.Arrow): start = arrow.get(start) @@ -31,7 +32,7 @@ def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): tags = [] return super(Frame, cls).__new__( - cls, start, stop, project, id, tags, updated_at + cls, start, stop, project, id, tags, updated_at, note ) def dump(self): @@ -39,7 +40,8 @@ def dump(self): stop = self.stop.to('utc').timestamp updated_at = self.updated_at.timestamp - return (start, stop, self.project, self.id, self.tags, updated_at) + return (start, stop, self.project, self.id, self.tags, updated_at, + self.note) @property def day(self): @@ -137,11 +139,11 @@ def add(self, *args, **kwargs): return frame def new_frame(self, project, start, stop, tags=None, id=None, - updated_at=None): + updated_at=None, note=None): if not id: id = uuid.uuid4().hex return Frame(start, stop, project, id, tags=tags, - updated_at=updated_at) + updated_at=updated_at, note=note) def dump(self): return tuple(frame.dump() for frame in self._rows) diff --git a/watson/utils.py b/watson/utils.py index c276860d..9fc4c8f8 100644 --- a/watson/utils.py +++ b/watson/utils.py @@ -68,7 +68,8 @@ def _style_short_id(id): 'error': {'fg': 'red'}, 'date': {'fg': 'cyan'}, 'short_id': _style_short_id, - 'id': {'fg': 'white'} + 'id': {'fg': 'white'}, + 'note': {'fg': 'white'}, } fmt = formats.get(name, {}) @@ -298,6 +299,7 @@ def frames_to_json(frames): ('stop', frame.stop.isoformat()), ('project', frame.project), ('tags', frame.tags), + ('note', frame.note), ]) for frame in frames ] @@ -320,6 +322,7 @@ def frames_to_csv(frames): ('stop', frame.stop.format('YYYY-MM-DD HH:mm:ss')), ('project', frame.project), ('tags', ', '.join(frame.tags)), + ('note', frame.note if frame.note else "") ]) for frame in frames ] @@ -400,3 +403,10 @@ def json_arrow_encoder(obj): return obj.for_json() raise TypeError("Object {} is not JSON serializable".format(obj)) + + +def format_note(note): + return u"{}{}".format( + style('note', '>> '), + style('note', note.replace('\n', '\n' + ' '*20)) + ) diff --git a/watson/watson.py b/watson/watson.py index 9b25a734..84794ea4 100644 --- a/watson/watson.py +++ b/watson/watson.py @@ -142,6 +142,7 @@ def save(self): 'project': self.current['project'], 'start': self._format_date(self.current['start']), 'tags': self.current['tags'], + 'note': self.current.get('note'), } else: current = {} @@ -203,7 +204,8 @@ def current(self, value): self._current = { 'project': value['project'], 'start': start, - 'tags': value.get('tags') or [] + 'tags': value.get('tags') or [], + 'note': value.get('note'), } if self._old_state is None: @@ -246,7 +248,7 @@ def add(self, project, from_date, to_date, tags): return frame def start(self, project, tags=None, restart=False, start_at=None, - gap=True): + gap=True, note=None): if self.is_started: raise WatsonError( "Project {} is already started.".format( @@ -270,7 +272,8 @@ def start(self, project, tags=None, restart=False, start_at=None, if start_at > arrow.now(): raise WatsonError('Task cannot start in the future.') - new_frame = {'project': project, 'tags': deduplicate(tags)} + new_frame = {'project': project, 'tags': deduplicate(tags), + 'note': note} new_frame['start'] = start_at if not gap: stop_of_prev_frame = self.frames[-1].stop @@ -278,7 +281,7 @@ def start(self, project, tags=None, restart=False, start_at=None, self.current = new_frame return self.current - def stop(self, stop_at=None): + def stop(self, stop_at=None, note=None): if not self.is_started: raise WatsonError("No project started.") @@ -296,9 +299,16 @@ def stop(self, stop_at=None): if stop_at > arrow.now(): raise WatsonError('Task cannot end in the future.') + if note is None: + note = old.get('note') + elif old.get('note') is not None: + print('Overwriting old note:\n>> {}'.format(old.get('note'))) + frame = self.frames.add( - old['project'], old['start'], stop_at, tags=old['tags'] + old['project'], old['start'], stop_at, tags=old['tags'], + note=note ) + self.current = None return frame @@ -485,6 +495,9 @@ def report(self, from_, to, current=None, projects=None, tags=None, span = self.frames.span(from_, to) + if tags is None: + tags = [] + frames_by_project = sorted_groupby( self.frames.filter( projects=projects or None, tags=tags or None, @@ -517,20 +530,34 @@ def report(self, from_, to, current=None, projects=None, tags=None, ) total += delta - project_report = { - 'name': project, - 'time': delta.total_seconds(), - 'tags': [] - } - - if tags is None: - tags = [] - tags_to_print = sorted( set(tag for frame in frames for tag in frame.tags if tag in tags or not tags) ) + project_notes = [] + for frame in frames: + # If the user is trying to print out all frames in the project + # (tags will be empty because no tags were passed) + if not tags and frame.note: + # And this frame has no tags... + if not frame.tags: + # Add it to the project-level notes because it + # won't get included in the tag-level notes + # because it has no tag. + project_notes.append(frame.note) + # And this frame has a tag... + else: + # Let the tag-level filter handle this frame later on + pass + + project_report = { + 'name': project, + 'time': delta.total_seconds(), + 'tags': [], + 'notes': project_notes, + } + for tag in tags_to_print: delta = reduce( operator.add, @@ -538,9 +565,13 @@ def report(self, from_, to, current=None, projects=None, tags=None, datetime.timedelta() ) + tag_notes = [frame.note for frame in frames + if tag in frame.tags and frame.note] + project_report['tags'].append({ 'name': tag, - 'time': delta.total_seconds() + 'time': delta.total_seconds(), + 'notes': tag_notes }) report['projects'].append(project_report)