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
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
\ 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 (
+ <>
+
+ Add New
+
+ >
+ );
+}
+
+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}
+
+
{ stopTimer(workItem) }}>STOP
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
+
+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 => (
+
+
+
+
+
+
+
{ restartTimer(workItem) }} className="flex items-center justify-center rounded bg-green-600 hover:bg-green-700 px-3 py-1 font-bold">Start
+
+
+ ))}
+ >
+ );
+}
+
+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 (
+ <>
+
+
+
+
+
+ YouTrack Timer
+
+
+
+
+
+
+
+
+ {userInfo?.avatarUrl && (
)}
+ {!userInfo?.avatarUrl && (
)}
+
+ {userInfo?.online && (
)}
+ {!userInfo?.online && (
)}
+
+
+
+
+
+
+
{userInfo?.fullName || 'YouTrack User'}
+
{userInfo?.email || 'email@email.com'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+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(
+
+
+
+ }>
+
+ }>
+ }>
+ }>
+ }>
+
+
+ }>
+
+
+
+
+ );
+})();
\ 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 (
+ <>
+
+ >
+ );
+}
+
+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
+
+ */}
+
+
Start Time *
+
+
+
+
+
+ {warning &&
+ {warning}
+
}
+
+
+
+
+
+ Notes / Reason
+ { 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" />
+
+
+ Create
+
+ >
+ );
+}
+
+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}
+
+
+
+
+ ))}
+
+
+ Add New
+
+
+
+
+
+
+ >
+ );
+}
+
+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.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 (
+ <>
+
+ >
+ );
+}
+
+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: []
+}
+