diff --git a/.github/actions/build-extension.yml b/.github/actions/build-extension.yml new file mode 100644 index 0000000..cc4f615 --- /dev/null +++ b/.github/actions/build-extension.yml @@ -0,0 +1,28 @@ +# Github Action that builds react extension and creates a zip file for deployment, updates the version in manifest.json from package.json and creates a release in github with the zip file +build-extension: + name: Build Extension + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: 20.x + - name: Install Dependencies + run: npm install + - name: Update Manifest + run: npm run update-manifest + - name: Build Extension + run: npm run build + - name: get-npm-version + id: package-version + uses: martinbeentjes/npm-get-version-action@v1.3.1 + - name: Zip Extension + run: zip -r ${{ steps.package-version.outputs.current-version}}.zip build + - name: Create Release + run: | + gh release create ${{ steps.package-version.outputs.current-version}} -F ${{ steps.package-version.outputs.current-version}}.zip + gh release upload ${{ steps.package-version.outputs.current-version}} ${{ steps.package-version.outputs.current-version}}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3ff5fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +package-lock.json \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9135b0a --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "youtrack-time-tracker", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.0", + "react-router-dom": "^6.14.1", + "tailwindcss": "^3.3.2" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "update-manifest": "node ./scripts/update-manifest.js" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "env": { + "webextensions": true + }, + "globals": { + "chrome": true + } + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} \ No newline at end of file diff --git a/public/background.js b/public/background.js new file mode 100644 index 0000000..b990273 --- /dev/null +++ b/public/background.js @@ -0,0 +1,16 @@ +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + // proxy for fetch requests + if (request.type === 'fetch') { + fetch(request.url, request.options).then((response) => { + if (!response.ok) { + sendResponse({ error: response.statusText }); + } else { + response.json().then((json) => { sendResponse({ response: json }) }).catch((error) => { sendResponse({ error: error.message }) }); + } + }).catch((error) => { + sendResponse({ error: error.message }); + }); + + return true; + } +}); \ No newline at end of file diff --git a/public/images/128x128_dark.png b/public/images/128x128_dark.png new file mode 100644 index 0000000..275bf9e Binary files /dev/null and b/public/images/128x128_dark.png differ diff --git a/public/images/128x128_light.png b/public/images/128x128_light.png new file mode 100644 index 0000000..f5d4a7a Binary files /dev/null and b/public/images/128x128_light.png differ diff --git a/public/images/16x16_dark.png b/public/images/16x16_dark.png new file mode 100644 index 0000000..c458d01 Binary files /dev/null and b/public/images/16x16_dark.png differ diff --git a/public/images/16x16_light.png b/public/images/16x16_light.png new file mode 100644 index 0000000..3c02ec4 Binary files /dev/null and b/public/images/16x16_light.png differ diff --git a/public/images/48x48_dark.png b/public/images/48x48_dark.png new file mode 100644 index 0000000..3b1ec72 Binary files /dev/null and b/public/images/48x48_dark.png differ diff --git a/public/images/48x48_light.png b/public/images/48x48_light.png new file mode 100644 index 0000000..7d32ec6 Binary files /dev/null and b/public/images/48x48_light.png differ diff --git a/public/images/icon_dark.png b/public/images/icon_dark.png new file mode 100644 index 0000000..79e6907 Binary files /dev/null and b/public/images/icon_dark.png differ diff --git a/public/images/icon_dark.svg b/public/images/icon_dark.svg new file mode 100644 index 0000000..391846a --- /dev/null +++ b/public/images/icon_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/icon_light.png b/public/images/icon_light.png new file mode 100644 index 0000000..723a797 Binary files /dev/null and b/public/images/icon_light.png differ diff --git a/public/images/icon_light.svg b/public/images/icon_light.svg new file mode 100644 index 0000000..25bb118 --- /dev/null +++ b/public/images/icon_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b5db511 --- /dev/null +++ b/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + YouTrack Timer + + + + +
+ + + \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..62e3471 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "version": "=version=", + "name": "YouTrack Timer", + "action": { + "default_icon": { + "16": "images/16x16_light.png", + "48": "images/48x48_light.png", + "128": "images/128x128_light.png" + }, + "default_title": "YouTrack Timer", + "default_popup": "index.html" + }, + "permissions": [ + "storage" + ], + "optional_host_permissions": [ + "https://*/*" + ], + "background": { + "service_worker": "background.js" + } +} \ No newline at end of file diff --git a/scripts/update-manifest.js b/scripts/update-manifest.js new file mode 100644 index 0000000..657244d --- /dev/null +++ b/scripts/update-manifest.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); + +const package = path.join(__dirname, "../package.json"); +const manifest = path.join(__dirname, "../public/manifest.json"); + +const packageJson = require(package); +const manifestJson = require(manifest); + +manifestJson.version = packageJson.version; + +const json = JSON.stringify(manifestJson, null, 4); + +fs.writeFileSync(manifest, json); + +console.log("Updated manifest.json with version from package.json"); \ No newline at end of file diff --git a/src/Store.js b/src/Store.js new file mode 100644 index 0000000..66bd770 --- /dev/null +++ b/src/Store.js @@ -0,0 +1,138 @@ + + +class Store { + + static types = { + BACKGROUND: 'BACKGROUND', + POPUP: 'POPUP', + WEB: 'WEB', + CONTENT: 'CONTENT' + } + + static getType() { + if (chrome && chrome.extension && chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window) return this.types.BACKGROUND; + else if (chrome && chrome.extension && chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() !== window) return this.types.POPUP; + else if (!chrome || !chrome.runtime || !chrome.runtime.onMessage) return this.types.WEB; + else return this.types.CONTENT; + } + + static getInstance() { + switch (this.getType()) { + case this.types.BACKGROUND: + return new ChromeExtensionStore(); + case this.types.POPUP: + return new ChromeExtensionStore(); + case this.types.WEB: + return new WebStore(); + case this.types.CONTENT: + return new WebStore(); + default: + throw new Error('Unknown store type'); + } + } + + + constructor() { + this.isConfigured = false; + this.baseUrl = null; + this.token = null; + this.userUrl = null; + } + + async load() { throw new Error('Not implemented') } + async save() { throw new Error('Not implemented') } + async clear() { throw new Error('Not implemented') } +} + + +class ChromeExtensionStore extends Store { + constructor() { super() } + + async load() { + return new Promise((resolve, reject) => { + chrome.storage.sync.get(['isConfigured', 'baseUrl', 'userUrl', 'token'], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + this.isConfigured = result.isConfigured; + this.baseUrl = result.baseUrl; + this.userUrl = result.userUrl; + this.token = result.token; + resolve(result); + } + }); + }); + } + + async save() { + return new Promise((resolve, reject) => { + chrome.storage.sync.set({ + isConfigured: this.isConfigured, + baseUrl: this.baseUrl, + userUrl: this.userUrl, + token: this.token + }, () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); + } + + async clear() { + return new Promise((resolve, reject) => { + chrome.storage.sync.clear(() => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); + } +} + + +class WebStore extends Store { + constructor() { super() } + + async load() { + return new Promise((resolve) => { + const isConfigured = localStorage.getItem('isConfigured'); + const baseUrl = localStorage.getItem('baseUrl'); + const userUrl = localStorage.getItem('userUrl'); + const token = localStorage.getItem('token'); + + this.isConfigured = isConfigured; + this.baseUrl = baseUrl; + this.userUrl = userUrl; + this.token = token; + + resolve({ isConfigured, baseUrl, userUrl, token }); + }); + } + + async save() { + return new Promise((resolve) => { + localStorage.setItem('isConfigured', this.isConfigured); + localStorage.setItem('baseUrl', this.baseUrl); + localStorage.setItem('userUrl', this.userUrl); + localStorage.setItem('token', this.token); + resolve(); + }); + } + + async clear() { + return new Promise((resolve) => { + localStorage.removeItem('isConfigured'); + localStorage.removeItem('baseUrl'); + localStorage.removeItem('userUrl'); + localStorage.removeItem('token'); + resolve(); + }); + } +} + +export default Store; \ No newline at end of file diff --git a/src/YouTrackAPI.js b/src/YouTrackAPI.js new file mode 100644 index 0000000..1a5f3f4 --- /dev/null +++ b/src/YouTrackAPI.js @@ -0,0 +1,171 @@ +class YouTrackAPI { + + constructor({ + baseUrl, + token, + userUrl + }) { + this.baseUrl = baseUrl; + this.userUrl = userUrl || baseUrl; + this.token = token; + this.currentUser = null; + } + + #checkConfig = async () => { + if (!this.baseUrl || !this.token) throw new Error('YouTrack API is not configured'); + } + + #fetch = async (url, _options = {}, headers = {}) => { + const options = { + headers: { + accept: 'application/json', + authorization: `Bearer ${this.token}`, + ...headers + }, + ..._options + } + + + // if we are in a chrome extension, use the background page to proxy the request + if (chrome && chrome.runtime) { + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ + type: 'fetch', + url: `${this.baseUrl}${url}`, + options + }, (response) => { + if (!response) return reject(new Error('No response')); + if (response.error) { + reject(response.error); + } else { + resolve(response); + } + }); + }) + + if (response.error) throw new Error(response.error); + + return response.response; + } else { + const response = await fetch(`${this.baseUrl}${url}`, options); + if (response.status === 204) return; + const json = await response.json(); + if (response.status >= 400) throw new Error(json.error_description); + return json; + } + } + + #get = async (url) => { + await this.#checkConfig(); + return await this.#fetch(url); + } + + #post = async (url, body) => { + await this.#checkConfig(); + return await this.#fetch(url, { + method: 'POST', + body: JSON.stringify(body), + }, { + 'Content-Type': 'application/json' + }); + } + + + async getCurrentUser() { + if (this.currentUser) return this.currentUser; + this.currentUser = await this.#get('/api/users/me?fields=email,fullName,login,name,id,avatarUrl,online,guest'); + return this.currentUser; + } + + async getOrganizations() { + return await this.#get('/api/admin/organizations?fields=id,name,description,iconUrl,projects(id,name),projectsCount,total'); + } + + async getProjects() { + return await this.#get('/api/admin/projects?fields=id,name,shortName,iconUrl,description,shortName'); + } + + async getProject(id) { + return await this.#get(`/api/admin/projects/${id}?fields=id,name,shortName`); + } + + async getIssuesForProject(id, openOnly = true) { + return await this.#get(`/api/issues?query=project:{${id}} order by: {created} desc ${openOnly ? 'State: -Resolved' : ''}&fields=id,idReadable,summary,resolved,updated,isDraft`); + } + + + async createIssue(projectId, summary, description = "") { + return await this.#post('/api/issues?fields=idReadable', { + project: { + id: projectId + }, + summary, + description + }); + } + + async createTimer(issueId, YTT, summary) { + return await this.#post(`/api/issues/${issueId}/timeTracking/workItems?fields=id`, { + text: `${YTT} ${summary}`, + duration: { minutes: 1 } + }); + } + + async updateTimer(issueId, timerId, minutes, summary = "") { + return await this.#post(`/api/issues/${issueId}/timeTracking/workItems/${timerId}`, { + text: summary, + duration: { minutes: minutes < 1 ? 1 : minutes } + }); + } + + parseYTT(text) { + // find all instances of [YTT.*] + const matches = text.match(/\[YTT.*?\]/g); + if (!matches) return null; + const match = matches[0].replace('[', '').replace(']', ''); + let parts = match.split('.'); + + const values = { + header: parts.shift(), + userId: parts.shift() + }; + for (const part of parts) { + if (!part.includes('::')) continue; + const [type, value] = part.split('::'); + values[type] = value; + } + + return values; + } + + constructYTT(userID, values) { + const items = Object.entries(values).map(([type, value]) => `${type}::${value}`).join('.'); + return `[YTT.${userID}.${items}]`; + } + + async getWorkItems(query = '', limit = 0) { + const fields = [ + 'id', + 'created', + 'updated', + 'text', + 'duration(presentation, minutes)', + 'date', + 'workType(id,name)', + 'issue(id,idReadable,summary,project(id,name,shortName))', + 'type(color(background),name)' + ].join(','); + const items = await this.#get(`/api/workItems?author=me&fields=${fields}${query ? `&query=${query}` : ''}${limit ? `&$top=${limit}` : ''}`); + + // filter the results down to only those where the text contains query -- the youtrack API returns old work items that don't match the query due to a bug or something + return items.map(item => { + if (item?.text) { + item.yttData = this.parseYTT(item.text); + } + return item; + }) + } + +} + +export default YouTrackAPI; \ No newline at end of file diff --git a/src/components/AddNewButton.js b/src/components/AddNewButton.js new file mode 100644 index 0000000..be23807 --- /dev/null +++ b/src/components/AddNewButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Link } from "react-router-dom"; + + +function AddNewButton() { + + return ( + <> + + + + + ); +} + +export default AddNewButton; diff --git a/src/components/CurrentTimers.js b/src/components/CurrentTimers.js new file mode 100644 index 0000000..90ce956 --- /dev/null +++ b/src/components/CurrentTimers.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { getReadableTimeSince } from '../functions'; +function CurrentTimers() { + + // returns a string like "1d 2h 30m 15s" if the time is exactly 3 hours still return "3h 0m 0s" + + + const [workItems, setWorkItems] = React.useState([]); + const [timerValues, setTimerValues] = React.useState({}); + const timers = {}; + + const loadWorkItems = async () => { + const currentUser = await window.yt.getCurrentUser(); + + const query = `[YTT.${currentUser.id}*active::` + const workItems = await window.yt.getWorkItems(query); + const tmpTimerValues = {}; + + const transformed = workItems.filter((workItem) => (workItem?.yttData?.active)).map(workItem => { + let startedAt = new Date(parseInt(`1${workItem.yttData.active}0000`)); + if (startedAt > new Date()) startedAt = new Date(); + + timers[workItem.id] = startedAt + tmpTimerValues[workItem.id] = getReadableTimeSince(startedAt); + + return { + ...workItem, + startedAt, + text: workItem.text.replace(/\[YTT.*]/, '').trim() + } + }) + + setTimerValues(tmpTimerValues); + setWorkItems(transformed); + } + + React.useEffect(() => { + loadWorkItems(); + + const reloader = setInterval(() => { + loadWorkItems(); + }, 1000) + + return () => { clearInterval(reloader) } + }, []); + + const stopTimer = async (workItem) => { + const currentUser = await window.yt.getCurrentUser(); + // calculate minutes since workItem.startedAt + const minutes = Math.floor((new Date() - workItem.startedAt) / 1000 / 60); + console.log("STOPPING", workItem) + // remove the timer from the workItem.text + const newText = `${window.yt.constructYTT(currentUser.id, { id: (workItem?.yttData?.id ? workItem.yttData.id : workItem.id) })} ${workItem.text.replace(/\[YTT.*]/g, '').trim()}` + + try { + await window.yt.updateTimer(workItem.issue.id, workItem.id, minutes, newText); + } catch (error) { + console.error(error); + } + } + + return ( + <> + {workItems.map(workItem => ( +
+
+
+

{timerValues[workItem.id]}

+

{workItem.text}

+
+ +
+ +
+ +
+ + Project + {workItem.issue.project.name} + + + Issue + {workItem.issue.idReadable} + + {workItem?.type && (
+ Type + {workItem?.type?.name} +
)} + {workItem?.duration?.presentation && workItem?.duration?.presentation !== "1m" && (
+ Time Spent + {workItem?.duration?.presentation} +
)} + +
+ +
+ ))} + + ); +} + +export default CurrentTimers; diff --git a/src/components/PreviousTimers.js b/src/components/PreviousTimers.js new file mode 100644 index 0000000..29edb7f --- /dev/null +++ b/src/components/PreviousTimers.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { getReadableTimeSince, parseDuration } from '../functions'; + +function PreviousTimers() { + + const [workItems, setWorkItems] = React.useState([]); + + const loadWorkItems = async () => { + const currentUser = await window.yt.getCurrentUser(); + + const query = `"[YTT.${currentUser.id}*" AND work date: {This Week}` + const workItems = await window.yt.getWorkItems(query, 100); + const activeList = workItems.filter((workItem) => (workItem?.yttData?.active)).map(workItem => workItem?.yttData?.id || workItem.id); + + const mergedItems = workItems + .sort((a, b) => (b.updated - a.updated)) + .filter((workItem) => (!workItem?.yttData?.active)) + .filter((workItem) => (!activeList.includes(workItem?.yttData?.id || workItem.id))) + .reduce((acc, workItem) => { + const id = workItem.yttData?.id || workItem.id; + + workItem.duration.parsed = parseDuration(workItem?.duration?.presentation || '0s'); + workItem.text = workItem.text.replace(/\[YTT.*]/, '').trim(); + + if (!acc[id]) acc[id] = workItem; + else { + acc[id].duration.parsed += workItem.duration.parsed; + acc[id].duration.presentation = getReadableTimeSince(new Date() - acc[id].duration.parsed * 1000).replace(' 0s', '') + } + return acc; + }, {}) + + + setWorkItems(Object.values(mergedItems)); + } + + React.useEffect(() => { + loadWorkItems(); + + const reloader = setInterval(() => { + loadWorkItems(); + }, 1000) + + return () => { clearInterval(reloader) } + }, []); + + const restartTimer = async (workItem) => { + const currentUser = await window.yt.getCurrentUser(); + const id = workItem.yttData?.id || workItem.id; + + const YTTStr = window.yt.constructYTT(currentUser.id, { active: ((new Date().getTime() / 1e4) - 1e8).toFixed(), id }); + try { + const newTimer = await window.yt.createTimer(workItem.issue.id, YTTStr, workItem.text); + console.log(newTimer); + } catch (e) { + console.log(e); + alert('Error: ' + e.message); + } + } + + return ( + <> + {workItems.length > 0 && (

Recent Timers

)} + {workItems.map(workItem => ( +
+
+

{workItem.text}

+
+
+ +
+ + Project + {workItem.issue.project.name} + + + Issue + {workItem.issue.idReadable} + + {workItem?.type && (
+ Type + {workItem?.type?.name} +
)} + {workItem?.duration?.presentation && (
+ Time Spent + {workItem?.duration?.presentation} +
)} +
+ + +
+
+ ))} + + ); +} + +export default PreviousTimers; diff --git a/src/components/TopBar.js b/src/components/TopBar.js new file mode 100644 index 0000000..a397586 --- /dev/null +++ b/src/components/TopBar.js @@ -0,0 +1,93 @@ + + +import React from 'react'; +import { Link } from "react-router-dom"; + +function TopBar() { + + const [menuOpen, setMenuOpen] = React.useState(false); + + const [userInfo, setUserInfo] = React.useState(null); + + React.useEffect(() => { + if (!window.yt) return; + window.yt.getCurrentUser().then(setUserInfo); + }, []); + + return ( + <> + +
+ + ) +} + +export default TopBar \ No newline at end of file diff --git a/src/functions.js b/src/functions.js new file mode 100644 index 0000000..b142392 --- /dev/null +++ b/src/functions.js @@ -0,0 +1,35 @@ +// returns a string like "1d 2h 30m 15s" if the time is exactly 3 hours still return "3h 0m 0s" +export const getReadableTimeSince = (date) => { + let seconds = Math.floor((new Date() - date) / 1000); + + let days = Math.floor(seconds / (3600 * 24)); + seconds -= days * 3600 * 24; + let hrs = Math.floor(seconds / 3600); + seconds -= hrs * 3600; + let mnts = Math.floor(seconds / 60); + seconds -= mnts * 60; + + let readableTime = ''; + if (days > 0) readableTime += `${days}d `; + if (hrs > 0 || days > 0) readableTime += `${hrs}h `; + if (mnts > 0 || days > 0 || hrs > 0) readableTime += `${mnts}m `; + if (seconds > 0 || days > 0 || hrs > 0 || mnts > 0) readableTime += `${seconds}s`; + + return readableTime; +} + + +export const parseDuration = (duration) => { + // parses a duration string provided like "1d 2h 30m 15s" and returns the number of seconds it represents + const durationParts = duration.split(' '); + let seconds = 0; + durationParts.forEach(part => { + const num = parseInt(part); + if (part.endsWith('d')) seconds += num * 24 * 60 * 60; + else if (part.endsWith('h')) seconds += num * 60 * 60; + else if (part.endsWith('m')) seconds += num * 60; + else if (part.endsWith('s')) seconds += num; + }); + return seconds; +} + diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..761caa3 --- /dev/null +++ b/src/index.css @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: white; + width: 500px; + min-height: 700px; + overflow-x: hidden; +} + +/* light mode */ +@media (prefers-color-scheme: light) { + body { + @apply bg-gray-100; + } +} + +@media (prefers-color-scheme: dark) { + body { + @apply bg-gray-800; + } +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type=number] { + -moz-appearance: textfield; +} + +* { + scrollbar-width: thin; + scrollbar-color: #ffffff; +} + +/* Chrome, Edge and Safari */ +*::-webkit-scrollbar { + width: 4px; +} + +*::-webkit-scrollbar-track { + border-radius: 3px; + background-color: #66666666; +} + +*::-webkit-scrollbar-track:hover { + background-color: #66666666; +} + +*::-webkit-scrollbar-track:active { + background-color: #66666666; +} + +*::-webkit-scrollbar-thumb { + border-radius: 3px; + background-color: #ffffff8a; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: #ffffff8a; +} + +*::-webkit-scrollbar-thumb:active { + background-color: #ffffff8a; +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..be6598a --- /dev/null +++ b/src/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { HashRouter, Routes, Route } from "react-router-dom"; +import YouTrackAPI from './YouTrackAPI' +import Store from './Store' + +import './index.css'; + +// pages import +import Configure from './pages/Configure'; +import Home from './pages/Home'; + +import SelectProject from './pages/AddNew/select-project'; +import SelectIssue from './pages/AddNew/select-issue'; +import CreateIssue from './pages/AddNew/create-issue'; +import CreateTimer from './pages/AddNew/create-timer'; + +// components import +import TopBar from './components/TopBar'; + +(async () => { + + const root = ReactDOM.createRoot(document.getElementById('root')); + + try { + window.storage = Store.getInstance() + + const { isConfigured, baseUrl, token, userUrl } = await window.storage.load(); + + if (!isConfigured) return root.render(); + + window.yt = new YouTrackAPI({ baseUrl, token, userUrl }) + } catch (e) { + alert("There was a fatal error while loading the extension. Your configuration has been reset. Please reconfigure the extension.") + await window.storage.clear() + } + + + root.render( + + + + }> + + }> + }> + }> + }> + + + }> + + +
+ + D3VL - Software That Rocks + +
+
+ ); +})(); \ No newline at end of file diff --git a/src/pages/AddNew/create-issue.js b/src/pages/AddNew/create-issue.js new file mode 100644 index 0000000..75d0b5d --- /dev/null +++ b/src/pages/AddNew/create-issue.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { useParams, useNavigate } from "react-router-dom"; + +function CreateIssue() { + + const { project } = useParams(); + const navigate = useNavigate(); + + + const [createEnabled, setCreateEnabled] = React.useState(false); + const summaryField = React.useRef(null); + const descriptionField = React.useRef(null); + + const doCreateIssue = async () => { + const projectData = await window.yt.getProject(project); + try { + const issue = await window.yt.createIssue(projectData.id, summaryField.current.value, descriptionField.current.value); + navigate("/add-new/" + project + "/" + issue.idReadable); + } catch (e) { + alert(e); + } + } + + + return ( + <> +
+

Create A New Issue

+ +
+ + { setCreateEnabled(e.target.value.length > 3) }} type="text" className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" /> +
+ +
+ + +
+ + + +
+ + ); +} + +export default CreateIssue; diff --git a/src/pages/AddNew/create-timer.js b/src/pages/AddNew/create-timer.js new file mode 100644 index 0000000..ae65401 --- /dev/null +++ b/src/pages/AddNew/create-timer.js @@ -0,0 +1,174 @@ +import React from 'react'; +import { useParams, useNavigate } from "react-router-dom"; + +function CreateTimer() { + + const { project, issue } = useParams(); + const navigate = useNavigate(); + + const [warning, setWarning] = React.useState(null); + const [ampm, setAmpm] = React.useState('??'); + const [hours, _setHours] = React.useState(0); + const [minutes, _setMinutes] = React.useState(0); + const hoursRef = React.useRef(null); + const minutesRef = React.useRef(null); + + React.useEffect(() => { + getStartTime(); + }, [hours, minutes, ampm]) + + React.useEffect(() => { + setHours(new Date().getHours() % 12) + setMinutes(new Date().getMinutes()) + setAmpm(new Date().getHours() >= 12 ? 'PM' : 'AM') + }, []) + + const setHours = (v) => { + // v = +1 or -1 + let newHours = hours; + if (hours === 12 && !v) newHours = 0; + else if (hours === 1 && !v) newHours = 12; + else newHours += v; + if (newHours <= 0) { + newHours = 12; + } + if (newHours > 12) { + newHours = 1; + } + _setHours(newHours); + } + + + const setMinutes = (v) => { + // v = +1 or -1 + let m = minutes + v; + if (m < 0) { + m = 59; + setHours(-1); + } + if (m > 59) { + m = 0; + setHours(+1); + } + _setMinutes(m); + } + + + + const [createEnabled, setCreateEnabled] = React.useState(false); + const summaryField = React.useRef(null); + + const getStartTime = () => { + // calculate the start time relative to now, remembering that if it's 1am, the user could have started the timer at 11pm the previous day (or 23:00) + const now = new Date(); + const hours = parseInt(hoursRef.current.value); + let realHours = hours; + const minutes = parseInt(minutesRef.current.value); + const isPM = (ampm === 'PM') + console.log(`${hours}:${minutes} ${ampm}`) + + if (hours === 12) { + realHours = 0; + } + + let startTime = new Date(); + startTime.setHours(isPM ? realHours + 12 : realHours); + startTime.setMinutes(minutes); + startTime.setSeconds(0); + startTime.setMilliseconds(0); + console.log(startTime) + + // if the start time is in the future, then it's actually yesterday, so subtract a day + if (startTime > now) { + startTime.setDate(startTime.getDate() - 1); + setWarning("The time selected is in the future, this will be recorded as yesterday's time! " + startTime.toLocaleString()) + console.log(startTime) + } else { + setWarning(null); + } + + return startTime; + } + + const doCreateTimer = async () => { + const currentUser = await window.yt.getCurrentUser(); + + const startTime = getStartTime(); + // this will break on Wed May 18 2033 03:33:20 ... 🤷‍♂️ + const YTTStr = window.yt.constructYTT(currentUser.id, { active: ((startTime.getTime() / 1e4) - 1e8).toFixed() }); + + const summary = summaryField.current.value; + + try { + const newTimer = await window.yt.createTimer(issue, YTTStr, summary); + navigate(`/`); + } catch (e) { + console.log(e); + alert(e.message); + } + + } + + let time = Intl.NumberFormat('en-GB', { minimumIntegerDigits: 2 }); + + return ( + <> +
+

Start Timer

+ + {/* + time picker HH:MM AM/PM + flex row with 3 columns + 1st column: hours + 2nd column: minutes + 3rd column: AM/PM + + each item has an up and down arrow to increment/decrement the value + + */} +
+ + + + +
+
+ + + +
+ + +
+ + + +
+ +
+ setAmpm(e.target.value)} /> + setAmpm(e.target.value)} /> + + +
+
+ + {warning &&
+ {warning} +
} +
+ + + +
+ + { setCreateEnabled(e.target.value.length > 3) }} type="text" className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" /> +
+ + +
+ + ); +} + +export default CreateTimer; diff --git a/src/pages/AddNew/select-issue.js b/src/pages/AddNew/select-issue.js new file mode 100644 index 0000000..661f6a2 --- /dev/null +++ b/src/pages/AddNew/select-issue.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { useParams } from "react-router-dom"; +import { Link } from "react-router-dom"; + +function SelectIssue() { + + const { project } = useParams(); + + + const [issues, setIssues] = React.useState([]); + + React.useEffect(() => { + (async () => { + const issues = await window.yt.getIssuesForProject(project, true) + + console.log(issues); + setIssues(issues); + + })() + }, []); + + return ( + <> +
+

Select Issue

+ + {issues.map(issue => ( + +
+
+
+ [{issue.idReadable}] +
+ {issue.summary} +
+
+
+ + ))} + + + + + + + + +
+ + ); +} + +export default SelectIssue; diff --git a/src/pages/AddNew/select-project.js b/src/pages/AddNew/select-project.js new file mode 100644 index 0000000..305c050 --- /dev/null +++ b/src/pages/AddNew/select-project.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { Link, useNavigate } from "react-router-dom"; + +function SelectProject() { + const [projects, setProjects] = React.useState([]); + const navigate = useNavigate(); + React.useEffect(() => { + (async () => { + const projects = await window.yt.getProjects(); + + // if the user has only one project, redirect to the add-new page for that project + if (projects.length === 1) return navigate("/add-new/" + projects[0].id); + + setProjects(projects); + })() + }, []); + + + return ( + <> +
+

Select Project

+ + + + {projects.map(project => ( + +
+
+ + Project Logo + +
+
{project.name}
+
[{project.shortName}] {project.description}
+
+
+
+ + ))} + + +
+ + + ); +} + +export default SelectProject; diff --git a/src/pages/Configure/index.js b/src/pages/Configure/index.js new file mode 100644 index 0000000..fd008cc --- /dev/null +++ b/src/pages/Configure/index.js @@ -0,0 +1,125 @@ +import React from 'react'; +import YouTrackAPI from '../../YouTrackAPI'; + + +function Configure() { + + const [saveEnabled, setSaveEnabled] = React.useState(false); + + const baseUrlRef = React.useRef(null); + const tokenRef = React.useRef(null); + const userUrlRef = React.useRef(null); + + + React.useEffect(() => { + const { baseUrl, userUrl, token } = window.storage; + + if (baseUrl) baseUrlRef.current.value = baseUrl; + if (userUrl) userUrlRef.current.value = userUrl; + if (token) tokenRef.current.value = token; + }, []); + + + const doSaveConfig = async () => { + + + + const baseUrl = baseUrlRef.current.value; + const userUrl = userUrlRef.current.value || baseUrl; + const token = tokenRef.current.value; + + if (!baseUrl || !userUrl || !token) { + if (!baseUrl) baseUrlRef.current.classList.add('border-red-500'); + if (!userUrl) userUrlRef.current.classList.add('border-red-500'); + if (!token) tokenRef.current.classList.add('border-red-500'); + } else { + baseUrlRef.current.classList.remove('border-red-500'); + userUrlRef.current.classList.remove('border-red-500'); + tokenRef.current.classList.remove('border-red-500'); + } + + let urlObj = null; + try { + urlObj = new URL(baseUrl); + } catch (err) { + alert('Invalid YouTrack URL'); + return; + } + + // check if we have permission to call baseUrl + const granted = await new Promise((resolve, reject) => { + try { + if (!chrome || !chrome?.permissions?.request) resolve(true); + else { + chrome?.permissions?.request({ + origins: [urlObj.href + '*'] + }, resolve); + } + } catch (err) { reject(err) } + }) + + if (!granted) return alert('Permission denied. Please allow the extension to access YouTrack.'); + + // test connection + try { + const yt = new YouTrackAPI({ baseUrl, token, userUrl }); + const account = await yt.getCurrentUser(); + + if (!account) { + alert('Connection failed. Please check your configuration.'); + return; + } + + // save config + window.storage.baseUrl = urlObj.origin; + window.storage.userUrl = userUrl; + window.storage.token = token; + window.storage.isConfigured = true; + await window.storage.save(); + + // reload + window.location = window.location.origin + '/index.html' + } catch (err) { + alert(err); + return; + } + } + + + return ( + <> +
+

Configuration

+ +
+ + + + This is the URL you use to access YouTrack + +
+ +
+ + + + Profile > Account Security > Tokens > Click "New Token" + +
+ +
+ + + + Ignore this if you use the same URL for API and User Login + +
+ + + +
+ + ); +} + +export default Configure; diff --git a/src/pages/Home/index.js b/src/pages/Home/index.js new file mode 100644 index 0000000..f0329f1 --- /dev/null +++ b/src/pages/Home/index.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import CurrentTimers from '../../components/CurrentTimers'; +import AddNewButton from '../../components/AddNewButton'; +import PreviousTimers from '../../components/PreviousTimers'; + +function Home() { + return ( + <> +
+ + +
+ +
+
+ + ); +} + +export default Home; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..3a5e206 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], + safelist: [] +} +