Skip to content

Commit eb256eb

Browse files
committed
Initial implementation
1 parent d166c81 commit eb256eb

File tree

7 files changed

+174
-4
lines changed

7 files changed

+174
-4
lines changed

app/views/timer_sessions/_timer_container.html.erb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
<div class="box" data-controller="form timer" data-timer-timezone-value="<%= offset_for_time_zone %>">
1+
<div class="box" data-controller="form timer" data-timer-timezone-value="<%= offset_for_time_zone %>"
2+
data-form-share-copied-value="<%= t('timer_sessions.timer.share_copied') %>"
3+
data-form-share-ignored-value="<%= t('timer_sessions.timer.share_ignored') %>"
4+
data-form-session-active-value="<%= timer_session.persisted? %>">
25
<div class="timer-container">
36
<% active_timer_session = timer_session %>
47
<%= labelled_form_for(active_timer_session,
@@ -88,6 +91,10 @@
8891
<%= t('timer_sessions.timer.continue_last_session') %>
8992
<%= sprite_icon('add') %>
9093
<% end %>
94+
<button type="button" data-name="timer-share" data-action="click->form#share" class="ml-3">
95+
<%= t('timer_sessions.timer.share') %>
96+
<%= sprite_icon('link') %>
97+
</button>
9198
<% end %>
9299
<% end %>
93100

assets.src/src/redmine-tracky/controllers/form-controller.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export default class extends Controller {
99
declare readonly absolutInputTarget: HTMLInputElement
1010
declare readonly descriptionTarget: HTMLInputElement
1111
declare readonly issueTargets: Element[]
12+
declare readonly shareCopiedValue: string
13+
declare readonly shareIgnoredValue: string
14+
declare readonly sessionActiveValue: boolean
1215

1316
private connected = false
1417

@@ -21,8 +24,15 @@ export default class extends Controller {
2124
'absolutInput',
2225
]
2326

27+
static values = {
28+
shareCopied: String,
29+
shareIgnored: String,
30+
sessionActive: Boolean,
31+
}
32+
2433
public connect() {
2534
this.connected = true
35+
this.showShareIgnoredNotice()
2636
}
2737

2838
public disconnect() {
@@ -67,6 +77,78 @@ export default class extends Controller {
6777
this.dispatchUpdate(form)
6878
}
6979

80+
public share(_event: Event) {
81+
const params = new URLSearchParams()
82+
const issueIds = this.extractIssueIds()
83+
const comments = this.descriptionTarget.value
84+
const timerStart = this.startTarget.value
85+
const timerEnd = this.endTarget.value
86+
87+
issueIds.forEach((id) => params.append('issue_ids[]', id))
88+
if (comments) params.set('comments', comments)
89+
if (timerStart) params.set('timer_start', timerStart)
90+
if (timerEnd) params.set('timer_end', timerEnd)
91+
92+
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`
93+
94+
this.copyToClipboard(url).then(() => {
95+
this.showFlashNotice(this.shareCopiedValue)
96+
})
97+
}
98+
99+
private copyToClipboard(text: string): Promise<void> {
100+
if (navigator.clipboard?.writeText) {
101+
return navigator.clipboard.writeText(text).catch(() => {
102+
this.copyToClipboardFallback(text)
103+
})
104+
}
105+
this.copyToClipboardFallback(text)
106+
return Promise.resolve()
107+
}
108+
109+
private copyToClipboardFallback(text: string) {
110+
const textarea = document.createElement('textarea')
111+
textarea.value = text
112+
textarea.style.position = 'fixed'
113+
textarea.style.opacity = '0'
114+
document.body.appendChild(textarea)
115+
textarea.select()
116+
document.execCommand('copy')
117+
document.body.removeChild(textarea)
118+
}
119+
120+
private showShareIgnoredNotice() {
121+
if (!this.sessionActiveValue) return
122+
123+
const urlParams = new URLSearchParams(window.location.search)
124+
const hasShareParams = urlParams.has('comments') ||
125+
urlParams.has('timer_start') ||
126+
urlParams.has('timer_end') ||
127+
urlParams.getAll('issue_ids[]').some((v) => v !== '')
128+
129+
if (hasShareParams) {
130+
this.showFlashNotice(this.shareIgnoredValue)
131+
}
132+
}
133+
134+
private showFlashNotice(message: string) {
135+
const flash = document.getElementById('flash_notice')
136+
if (flash) {
137+
flash.textContent = message
138+
flash.style.display = ''
139+
return
140+
}
141+
142+
const container = document.getElementById('content')
143+
if (!container) return
144+
145+
const notice = document.createElement('div')
146+
notice.id = 'flash_notice'
147+
notice.className = 'flash notice'
148+
notice.textContent = message
149+
container.prepend(notice)
150+
}
151+
70152
private extractIssueIds(): string[] {
71153
return (
72154
this.issueTargets

assets.src/src/redmine-tracky/controllers/issue-completion-controller.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default class extends Controller {
1818

1919
connect() {
2020
this.listenForInput()
21-
this.fetchIssuesFromURL()
21+
this.prefillFromURL()
2222
}
2323

2424
private listenForInput() {
@@ -43,8 +43,16 @@ export default class extends Controller {
4343
)
4444
}
4545

46-
private fetchIssuesFromURL() {
46+
private prefillFromURL() {
4747
const urlParams = new URLSearchParams(window.location.search)
48+
49+
this.prefillIssuesFromURL(urlParams)
50+
this.prefillFieldFromURL(urlParams, 'comments', '#timer_session_comments')
51+
this.prefillFieldFromURL(urlParams, 'timer_start', '#timer_session_timer_start')
52+
this.prefillFieldFromURL(urlParams, 'timer_end', '#timer_session_timer_end')
53+
}
54+
55+
private prefillIssuesFromURL(urlParams: URLSearchParams) {
4856
const issueIds = urlParams.getAll('issue_ids[]')
4957

5058
issueIds.filter(v => v !== "").forEach((id) => {
@@ -62,6 +70,17 @@ export default class extends Controller {
6270
})
6371
}
6472

73+
private prefillFieldFromURL(urlParams: URLSearchParams, param: string, selector: string) {
74+
const value = urlParams.get(param)
75+
if (!value) return
76+
77+
const field = document.querySelector<HTMLInputElement>(selector)
78+
if (field) {
79+
field.value = value
80+
field.dispatchEvent(new Event('change'))
81+
}
82+
}
83+
6584
private addIssue(issue: { item: CompletionResult }) {
6685
const listController =
6786
this.application.getControllerForElementAndIdentifier(

assets/javascripts/redmine-tracky.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/locales/de.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ de:
7373
timer:
7474
start: Start
7575
continue_last_session: Anschliessend Starten
76+
share: Teilen
77+
share_copied: Link in die Zwischenablage kopiert
78+
share_ignored: Ein Timer läuft bereits. Die geteilten Parameter wurden ignoriert.
7679
stop: Stop
7780
cancel: Abbrechen
7881
date_placeholder: 'dd.mm.yyyy hh:mm'

config/locales/en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ en:
7777
timer:
7878
start: Start
7979
continue_last_session: Start on end of last session
80+
share: Share
81+
share_copied: Link copied to clipboard
82+
share_ignored: A timer is already running. Shared session parameters were ignored.
8083
stop: Stop
8184
cancel: Cancel
8285
date_placeholder: 'dd.mm.yyyy hh:mm'

test/system/timer_management_test.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,62 @@ class TimerManagementTest < ApplicationSystemTestCase
119119
assert_no_text Issue.second.subject
120120
end
121121

122+
test 'loading timer with comments from url' do
123+
visit timer_sessions_path(comments: 'Meeting with team')
124+
125+
assert_equal 'Meeting with team', find('#timer_session_comments').value
126+
end
127+
128+
test 'loading timer with timer_start from url' do
129+
visit timer_sessions_path(timer_start: '01.01.2026 09:00')
130+
131+
assert_equal '01.01.2026 09:00', find('#timer_session_timer_start').value
132+
end
133+
134+
test 'loading timer with timer_end from url' do
135+
visit timer_sessions_path(timer_end: '01.01.2026 17:00')
136+
137+
assert_equal '01.01.2026 17:00', find('#timer_session_timer_end').value
138+
end
139+
140+
test 'loading timer with all query params from url' do
141+
visit timer_sessions_path(
142+
issue_ids: [Issue.first.id],
143+
comments: 'Sprint planning',
144+
timer_start: '01.01.2026 09:00',
145+
timer_end: '01.01.2026 10:00'
146+
)
147+
148+
assert_text Issue.first.subject
149+
assert_equal 'Sprint planning', find('#timer_session_comments').value
150+
assert_equal '01.01.2026 09:00', find('#timer_session_timer_start').value
151+
assert_equal '01.01.2026 10:00', find('#timer_session_timer_end').value
152+
end
153+
154+
test 'share button is visible and clickable' do
155+
visit timer_sessions_path
156+
157+
fill_in 'timer_session_comments', with: 'Pairing session'
158+
159+
find('[data-name="timer-share"]', wait: 5).click
160+
assert_text I18n.t('timer_sessions.timer.share_copied')
161+
end
162+
163+
test 'share button not visible when timer is active' do
164+
FactoryBot.create(:timer_session, finished: false, user: User.current)
165+
visit timer_sessions_path
166+
167+
assert has_content?(I18n.t('timer_sessions.timer.stop'))
168+
assert_no_selector('[data-name="timer-share"]')
169+
end
170+
171+
test 'shows prefill notice when active session exists and url has params' do
172+
FactoryBot.create(:timer_session, finished: false, user: User.current)
173+
visit timer_sessions_path(comments: 'Meeting', issue_ids: [Issue.first.id])
174+
175+
assert_text I18n.t('timer_sessions.timer.share_ignored')
176+
end
177+
122178
test 'preserves filter parameters when stopping a timer' do
123179
filter_date = 1.week.ago.strftime('%Y-%m-%d')
124180
current_date = Date.today.strftime('%Y-%m-%d')

0 commit comments

Comments
 (0)