fewald-worklog 0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/storage.rb ADDED
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rainbow'
4
+ require 'daily_log'
5
+ require 'log_entry'
6
+ require 'worklogger'
7
+ require 'person'
8
+
9
+ # Handles storage of daily logs and people
10
+ class Storage
11
+ # LogNotFoundError is raised when a log file is not found
12
+ class LogNotFoundError < StandardError; end
13
+
14
+ FILE_SUFFIX = '.yaml'
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ def folder_exists?
21
+ Dir.exist?(@config.storage_path)
22
+ end
23
+
24
+ # Return all logs for all available days
25
+ # @return [Array<DailyLog>] List of all logs
26
+ def all_days
27
+ return [] unless folder_exists?
28
+
29
+ logs = []
30
+ Dir.glob(File.join(@config.storage_path, "*#{FILE_SUFFIX}")).map do |file|
31
+ next if file.end_with?('people.yaml')
32
+
33
+ logs << load_log(file)
34
+ end
35
+
36
+ logs
37
+ end
38
+
39
+ # Return all tags as a set
40
+ # @return [Set<String>] Set of all tags
41
+ def tags
42
+ logs = all_days
43
+ tags = Set[]
44
+ logs.each do |log|
45
+ log.entries.each do |entry|
46
+ next unless entry.tags
47
+
48
+ entry.tags.each do |tag|
49
+ tags << tag
50
+ end
51
+ end
52
+ end
53
+ tags
54
+ end
55
+
56
+ # Return days between start_date and end_date
57
+ # If end_date is nil, return logs from start_date to today
58
+ #
59
+ # @param [Date] start_date The start date, inclusive
60
+ # @param [Date] end_date The end date, inclusive
61
+ # @param [Boolean] epics_only If true, only return logs with epic entries
62
+ # @param [Array<String>] tags_filter If provided, only return logs with entries that have at least one of the tags
63
+ # @return [Array<DailyLog>] List of logs
64
+ def days_between(start_date, end_date = nil, epics_only = nil, tags_filter = nil)
65
+ return [] unless folder_exists?
66
+
67
+ logs = []
68
+ end_date = Date.today if end_date.nil?
69
+
70
+ return [] if start_date > end_date
71
+
72
+ while start_date <= end_date
73
+ if File.exist?(filepath(start_date))
74
+ tmp_logs = load_log!(filepath(start_date))
75
+ tmp_logs.entries.keep_if { |entry| entry.epic? } if epics_only
76
+
77
+ if tags_filter
78
+ # Safeguard against entries without any tags, not just empty array
79
+ tmp_logs.entries.keep_if { |entry| entry.tags && (entry.tags & tags_filter).size > 0 }
80
+ end
81
+
82
+ logs << tmp_logs if tmp_logs.entries.length > 0
83
+ end
84
+
85
+ start_date += 1
86
+ end
87
+ logs
88
+ end
89
+
90
+ # Create file for a new day if it does not exist
91
+ # @param [Date] date The date, used as the file name.
92
+ def create_file_skeleton(date)
93
+ create_folder
94
+
95
+ File.write(filepath(date), YAML.dump(DailyLog.new(date:, entries: []))) unless File.exist?(filepath(date))
96
+ end
97
+
98
+ def load_log(file)
99
+ load_log!(file)
100
+ rescue LogNotFoundError
101
+ WorkLogger.error "No work log found for #{file}. Aborting."
102
+ nil
103
+ end
104
+
105
+ def load_log!(file)
106
+ WorkLogger.debug "Loading file #{file}"
107
+ begin
108
+ log = YAML.load_file(file, permitted_classes: [Date, Time, DailyLog, LogEntry])
109
+ log.entries.each do |entry|
110
+ entry.time = Time.parse(entry.time) unless entry.time.respond_to?(:strftime)
111
+ end
112
+ log
113
+ rescue Errno::ENOENT
114
+ raise LogNotFoundError
115
+ end
116
+ end
117
+
118
+ def write_log(file, daily_log)
119
+ create_folder
120
+
121
+ WorkLogger.debug "Writing to file #{file}"
122
+
123
+ File.open(file, 'w') do |f|
124
+ f.puts daily_log.to_yaml
125
+ end
126
+ end
127
+
128
+ def load_single_log_file(file, headline = true)
129
+ daily_log = load_log!(file)
130
+ puts "Work log for #{Rainbow(daily_log.date).gold}:" if headline
131
+ daily_log.entries
132
+ end
133
+
134
+ def load_people
135
+ load_people!
136
+ rescue Errno::ENOENT
137
+ WorkLogger.info 'Unable to load people.'
138
+ []
139
+ end
140
+
141
+ # Load all people from the people file
142
+ # @return [Array<Person>] List of people
143
+ def load_people!
144
+ people_file = File.join(@config.storage_path, 'people.yaml')
145
+ return [] unless File.exist?(people_file)
146
+
147
+ YAML.load_file(people_file, permitted_classes: [Person])
148
+ end
149
+
150
+ # Write people to the people file
151
+ # @param [Array<Person>] people List of people
152
+ def write_people!(people)
153
+ create_folder
154
+
155
+ people_file = File.join(@config.storage_path, 'people.yaml')
156
+ File.open(people_file, 'w') do |f|
157
+ f.puts people.to_yaml
158
+ end
159
+ end
160
+
161
+ # Create folder if not exists already.
162
+ def create_folder
163
+ Dir.mkdir(@config.storage_path) unless Dir.exist?(@config.storage_path)
164
+ end
165
+
166
+ # Construct filepath for a given date.
167
+ # @param [Date] date The date
168
+ # @return [String] The filepath
169
+ def filepath(date)
170
+ File.join(@config.storage_path, "#{date}#{FILE_SUFFIX}")
171
+ end
172
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers for String manipulation
4
+ module StringHelper
5
+ # Pluralize a word based on a count. If the plural form is irregular, it can be provided.
6
+ # Otherwise, it will be generated automatically.
7
+ #
8
+ # @param count [Integer] the count to base the pluralization on
9
+ # @param singular [String] the singular form of the word
10
+ # @param plural [String] the plural form of the word, if it is irregular. Otherwise it will be generated.
11
+ # @return [String] the pluralized word
12
+ def pluralize(count, singular, plural = nil)
13
+ if count == 1
14
+ singular
15
+ else
16
+ return plural if plural
17
+
18
+ return "#{singular[0..-2]}ies" if singular.end_with? 'y'
19
+
20
+ return "#{singular}es" if singular.end_with? 'ch', 's', 'sh', 'x', 'z'
21
+
22
+ return "#{singular[0..-2]}ves" if singular.end_with? 'f', 'fe'
23
+
24
+ "#{singular}s"
25
+ end
26
+ end
27
+
28
+ # Format a string to be left-aligned in a fixed-width field
29
+ #
30
+ # @param string [String] the string to format
31
+ # @return [String] the formatted string
32
+ def format_left(string)
33
+ format('%18s', string)
34
+ end
35
+ end
data/lib/summary.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'httparty'
5
+ require 'json'
6
+
7
+ require 'worklogger'
8
+
9
+ # AI Summary generation.
10
+ class Summary
11
+ MODEL = 'llama3.2'
12
+ SUMMARY_INSTRUCTION = <<~INSTRUCTION
13
+ <% entries.each do |entry| -%>
14
+ <%= entry.message %>
15
+ <% end -%>
16
+ INSTRUCTION
17
+
18
+ SYSTEM_INSTRUCTION = <<~INSTRUCTION
19
+ You are a professional summarization assistant specialized in crafting performance review summaries. Your role is to take a list of achievements provided by the user and generate a concise, professional summary suitable for use in a formal performance review.
20
+
21
+ Guidelines:
22
+
23
+ Accuracy: Do not invent or infer any facts not explicitly provided by the user. Use only the information given.
24
+ Tone: Maintain a formal, professional tone throughout the summary.
25
+ Structure: Organize the summary in a coherent manner, emphasizing key accomplishments and their impact.
26
+ Clarity: Use clear and concise language, avoiding jargon unless specified by the user.
27
+ Your Task:
28
+
29
+ Analyze the list of achievements provided by the user.
30
+ Identify the key themes and accomplishments.
31
+ Draft a polished summary that highlights the individual’s contributions and results.
32
+ Constraints:
33
+
34
+ Do not fabricate details or add context that has not been explicitly stated.
35
+ Always prioritize clarity and professionalism in your writing.
36
+ Example Input:
37
+
38
+ "Exceeded sales targets by 15% in Q3."
39
+ "Implemented a new CRM system, reducing customer response time by 30%."
40
+ "Mentored two junior team members, both of whom received promotions."
41
+ Example Output: "[Name] demonstrated outstanding performance during the review period. Key accomplishments include exceeding sales targets by 15% in Q3, implementing a new CRM system that improved customer response times by 30%, and mentoring two junior team members who achieved career advancements. These achievements highlight [Name]'s exceptional contributions to team success and organizational growth."
42
+ INSTRUCTION
43
+
44
+ def initialize(config)
45
+ @config = config
46
+ end
47
+
48
+ # Build the prompt from provided log entries.
49
+ def build_prompt(log_entries)
50
+ ERB.new(SUMMARY_INSTRUCTION, trim_mode: '-').result_with_hash(entries: log_entries)
51
+ end
52
+
53
+ # Generate a summary from provided log entries.
54
+ # @param log_entries [Array<LogEntry>] The log entries to summarize.
55
+ # @return [String] The generated summary.
56
+ def generate_summary(log_entries)
57
+ prompt = build_prompt(log_entries)
58
+
59
+ WorkLogger.debug("Using prompt: #{prompt}")
60
+
61
+ begin
62
+ response = HTTParty.post('http://localhost:11434/api/generate',
63
+ body: {
64
+ model: Summary::MODEL,
65
+ prompt:,
66
+ system: Summary::SYSTEM_INSTRUCTION,
67
+ stream: false
68
+ }.to_json,
69
+ headers: { 'Content-Type' => 'application/json' })
70
+ response.parsed_response['response']
71
+ rescue Errno::ECONNREFUSED
72
+ WorkLogger.error <<~MSG
73
+ Ollama doesn't seem to be running. Please start the server and try again.
74
+ puts 'You can download Ollama at https://ollama.com'
75
+ MSG
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3
+ <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
4
+ <rect fill="#fff" stroke="#000" x="0" y="0" width="128" height="128" stroke-width="4" />
5
+ <rect fill="#fff" stroke="#000" x="32" y="32" width="64" height="64" stroke-width="2" />
6
+ <text x="10" y="30" fill="green" font-size="32"><%= Date.today %></text>
7
+ </svg>
@@ -0,0 +1,148 @@
1
+ <html>
2
+ <head>
3
+ <title>Work log</title>
4
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
5
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
6
+ <link rel="icon" href="favicon.svg">
7
+ <link rel="mask-icon" href="favicon.svg" color="#000000">
8
+ <style type="text/css">
9
+
10
+ ul {
11
+ list-style-type: none;
12
+ padding: 0 1rem;
13
+ }
14
+
15
+ /* Special style for presentation mode */
16
+ .presentation {
17
+ .day {
18
+ font-size: 2rem;
19
+ line-height: 2.6rem;
20
+
21
+ ul {
22
+ border-bottom: 2px solid #AAA;
23
+ li {
24
+ padding-bottom: 1rem;
25
+ }
26
+ }
27
+
28
+ .entries {
29
+ display: none;
30
+ }
31
+ }
32
+ }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <div class="container <%= presentation ? 'presentation' : '' %>">
37
+ <nav class="navbar navbar-expand-lg bg-body-tertiary">
38
+ <div class="container-fluid">
39
+ <a class="navbar-brand" href="/">
40
+ <% if tags %>
41
+ <%= tags.first.capitalize %> items
42
+ <% else %>
43
+ Work log
44
+ <% end %>
45
+ </a>
46
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
47
+ <span class="navbar-toggler-icon"></span>
48
+ </button>
49
+ <div class="collapse navbar-collapse" id="navbarSupportedContent">
50
+ <div class="navbar-nav me-auto"></div>
51
+ <div class="d-flex">
52
+ <% if presentation %>
53
+ <a href="<%= update_query({'presentation' => nil}) %>" class="btn btn-primary">
54
+ Screen
55
+ </a>
56
+ <% else %>
57
+ <a href="<%= update_query({'presentation' => true}) %>" class="btn btn-primary">
58
+ Presentation
59
+ </a>
60
+ <% end %>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+
66
+ </nav>
67
+ <hr class="border border-primary border-2 opacity-75">
68
+ <div class="pb-4">
69
+ Show
70
+ <div class="dropdown d-inline">
71
+ <a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
72
+ <% if epics_only %>
73
+ only epics
74
+ <% else %>
75
+ all items
76
+ <% end %>
77
+ </a>
78
+ <ul class="dropdown-menu">
79
+ <li><a class="dropdown-item" href="<%= update_query({'epics_only' => false}) %>">all items</a></li>
80
+ <li><a class="dropdown-item" href="<%= update_query({'epics_only' => true}) %>">only epics</a></li>
81
+ </ul>
82
+ </div>
83
+ from the last
84
+ <div class="dropdown d-inline">
85
+ <a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
86
+ <%= days %> days
87
+ </a>
88
+ <ul class="dropdown-menu">
89
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 7}) %>">7 days</a></li>
90
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 14}) %>">2 weeks</a></li>
91
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 21}) %>">3 weeks</a></li>
92
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 28}) %>">4 weeks</a></li>
93
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 60}) %>">2 months</a></li>
94
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 90}) %>">3 months</a></li>
95
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 180}) %>">6 months</a></li>
96
+ <li><a class="dropdown-item" href="<%= update_query({'days' => 365}) %>">1 year</a></li>
97
+ </ul>
98
+ </div>
99
+ of work with
100
+ <div class="dropdown d-inline">
101
+ <a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
102
+ <% if tags %>
103
+ <%= tags.size > 1 ? 'multiple tags' : "#{tags.first} tags" %>
104
+ <% else %>
105
+ all tags
106
+ <% end %>
107
+ </a>
108
+ <ul class="dropdown-menu">
109
+ <li><a class="dropdown-item" href="<%= update_query({'tags' => nil}) %>">all tags</a></li>
110
+ <% @tags.to_a.each do |tag| %>
111
+ <li><a class="dropdown-item" href="<%= update_query({'tags' => [tag]}) %>"><%= tag %> tags</a></li>
112
+ <% end %>
113
+ </ul>
114
+ </div>
115
+ .
116
+ </div>
117
+
118
+ <%- logs.each do |log| -%>
119
+ <section class="day">
120
+ <strong><%= log.date.strftime('%a, %B %-d, %Y') %></strong>
121
+
122
+ <ul>
123
+ <%- log.entries.each do |entry| -%>
124
+ <li>
125
+ <code><%= entry.time.strftime('%H:%M') %></code>
126
+ <% if entry.epic %>
127
+ <span class="badge text-bg-warning">EPIC</span>
128
+ <% end%>
129
+ <%= entry.message %>
130
+ <% if entry.tags and entry.tags.size > 0 %>
131
+ <% entry.tags.each do |tag| %>
132
+ <strong class="badge text-bg-secondary"><%= tag %></strong>
133
+ <% end %>
134
+ <% end %>
135
+ </li>
136
+ <%- end %>
137
+ </ul>
138
+ <p class="entries"><%= log.entries.size %> entries</p>
139
+ </section>
140
+ <%- end %>
141
+ <p><%= total_entries %> entries total</p>
142
+ </div>
143
+ <hr/>
144
+ <footer class="container pb-4 text-muted">
145
+ Generated at <%= Time.now.strftime('%Y-%m-%d %H:%M %Z') %>
146
+ </footer>
147
+ </body>
148
+ </html>
data/lib/version.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ # Returns the current version of the gem from `.version`.
6
+ # Versioning follows SemVer.
7
+ # @return [String] The current version of the gem.
8
+ def current_version
9
+ version_file_path = File.join(Pathname.new(__dir__).parent, '.version')
10
+ File.read(version_file_path).strip
11
+ end
12
+
13
+ # Increment version number according to SemVer.
14
+ # @param version [String] The current version.
15
+ # @param part [String] The part of the version to increment.
16
+ # @return [String] The incremented version.
17
+ def increment_version(version, part = 'patch')
18
+ major, minor, patch = version.split('.').map(&:to_i)
19
+ case part
20
+ when 'major'
21
+ major += 1
22
+ minor = 0
23
+ patch = 0
24
+ when 'minor'
25
+ minor += 1
26
+ patch = 0
27
+ when 'patch'
28
+ patch += 1
29
+ end
30
+ [major, minor, patch].join('.')
31
+ end
data/lib/webserver.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'erb'
5
+ require 'rack'
6
+ require 'rack/constants'
7
+ require 'rackup'
8
+ require 'uri'
9
+ require 'storage'
10
+ require 'worklog'
11
+
12
+ class DefaultHeaderMiddleware
13
+ # Rack middleware to add default headers to the response.
14
+
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ def call(env)
20
+ status, headers, body = @app.call(env)
21
+ headers[Rack::CONTENT_TYPE] ||= 'text/html'
22
+ headers[Rack::CACHE_CONTROL] ||= 'no-cache'
23
+ [status, headers, body]
24
+ end
25
+ end
26
+
27
+ # Class to render the main page of the WorkLog web application.
28
+ class WorkLogResponse
29
+ def initialize(storage, tags)
30
+ @storage = storage
31
+ @tags = tags
32
+ end
33
+
34
+ def response(request)
35
+ template = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'index.html.erb')), trim_mode: '-')
36
+ @params = request.params
37
+ days = @params['days'].nil? ? 7 : @params['days'].to_i
38
+ tags = @params['tags'].nil? ? nil : @params['tags'].split(',')
39
+ epics_only = @params['epics_only'] == 'true'
40
+ presentation = @params['presentation'] == 'true'
41
+ logs = @storage.days_between(Date.today - days, Date.today, epics_only, tags).reverse
42
+ total_entries = logs.sum { |entry| entry.entries.length }
43
+ _ = total_entries
44
+ _ = presentation
45
+
46
+ [200, {}, [template.result(binding)]]
47
+ end
48
+
49
+ private
50
+
51
+ def update_query(new_params)
52
+ uri = URI.parse('/')
53
+ cloned = @params.clone
54
+ new_params.each do |key, value|
55
+ cloned[key] = value
56
+ end
57
+ uri.query = URI.encode_www_form(cloned)
58
+ uri
59
+ end
60
+
61
+ def build_uri(params)
62
+ uri = URI.parse('/')
63
+ uri.query = URI.encode_www_form(params)
64
+ uri.to_s
65
+ end
66
+ end
67
+
68
+ class WorkLogApp
69
+ def initialize(storage)
70
+ @storage = storage
71
+ @tags = @storage.tags
72
+ end
73
+
74
+ def call(env)
75
+ req = Rack::Request.new(env)
76
+ WorkLogResponse.new(@storage, @tags).response(req)
77
+ end
78
+ end
79
+
80
+ # class FaviconApp
81
+ # # Rack application that creates a favicon.
82
+
83
+ # def self.call(_env)
84
+ # content = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'favicon.svg.erb')))
85
+ # [200, { Rack::CONTENT_TYPE => 'image/svg+xml' }, [content.result]]
86
+ # end
87
+ # end
88
+
89
+ class WorkLogServer
90
+ # Main Rack server containing all endpoints.
91
+ def initialize(worklog_app)
92
+ @worklog_app = worklog_app
93
+ end
94
+
95
+ def start
96
+ worklog_app = @worklog_app
97
+ app = Rack::Builder.new do
98
+ use Rack::Deflater
99
+ use Rack::CommonLogger
100
+ use Rack::ShowExceptions
101
+ use Rack::ShowStatus
102
+ use DefaultHeaderMiddleware
103
+
104
+ map '/' do
105
+ run worklog_app
106
+ end
107
+ # TODO: Future development
108
+ # map '/favicon.svg' do
109
+ # run FaviconApp
110
+ # end
111
+ end
112
+
113
+ Rackup::Server.start app: app
114
+ end
115
+ end