From b350b8f4d002fc823aeff5158d8346499d2ed334 Mon Sep 17 00:00:00 2001 From: Timeo Williams Date: Wed, 2 Oct 2024 14:41:33 -0400 Subject: [PATCH] feat: add circular chart in Home page for users to see current progress --- README.md | 43 +------- src/main/childProcess.ts | 19 ++-- src/main/index.ts | 12 ++- src/main/worker.ts | 24 +++-- src/renderer/src/App.tsx | 3 +- src/renderer/src/BarChart.tsx | 67 ++++++------ src/renderer/src/CircularProgress.tsx | 34 +++++++ src/renderer/src/Home.tsx | 118 +++++++++------------- src/renderer/src/Onboarding.tsx | 18 +--- src/renderer/src/SandTimer.tsx | 81 +++++++++++++++ src/renderer/src/Settings.tsx | 72 ++++--------- src/renderer/src/UnproductiveApps.tsx | 21 ++-- src/renderer/src/UnproductiveWebsites.tsx | 2 +- 13 files changed, 272 insertions(+), 242 deletions(-) create mode 100644 src/renderer/src/CircularProgress.tsx create mode 100644 src/renderer/src/SandTimer.tsx diff --git a/README.md b/README.md index 0b66a89..9eb56d2 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,17 @@ Note, for running this app locally, you may run into issues with active-window. - [x] Implement changelog using conventional commits - [x] Add integration and automated tests - [x] Implement user authentication and cloud-based data persistence +- [x] Implement progress bar for deep work visualization +- [x] Improve user onboarding experience +- [x] Use inspiration from debugtron to render the electron apps most commonly used. Use another API service to get the favicons of the top websites and include this in email and in the desktop app. - [ ] Allow users to enter session goals and customize productive/unproductive sites - [ ] Migrate from electron-storage to SQLite for improved data handling -- [ ] Implement progress bar for deep work visualization - [ ] Enhance data analysis and insights -- [ ] Improve user onboarding experience - [ ] Develop comprehensive test suite for main and renderer processes - [ ] Create cloud synchronization for user data and preferences - [ ] Implement secure user authentication system -- [ ] Use inspiration from debugtron to render the electron apps most commonly used. Use another API service to get the favicons of the top websites and include this in email and in the desktop app. - [ ] Collect each site visited. Show users all sites visited in the past day at the end of the day/next day and ask them to label them as productive or unproductive. - [ ] To do list like functionality? Have people add tasks to their list and mark as productive or not productive. Then, at the end of the day, they can see a list of tasks and see how productive they were. -- [ ] Add a light ## Philosophy @@ -101,39 +100,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Contact Timeo Williams [@timeowilliams](https://twitter.com/timeowilliams) - timeo.williams@gmail.com - -Problems: - -1. UI for Analytics doesn't send a request to get fresh data after 24 hours if - the user stays on the same Analytics page. Perhaps a refresh button would be - better? Or force a refresh after a certain amount of time? - -2. Need to allow customers to add websites that are productive - so that we can track the time spent on those sites. - -As a default, the Login Window and Settings page should be hidden. -windowInfo { -owner: { -name: 'loginwindow', -processId: 186, -bundleId: 'com.apple.loginwindow', -path: '/System/Library/CoreServices/loginwindow.app' -}, -bounds: { width: 30000, y: -15000, height: 30000, x: -15000 }, -memoryUsage: 18672, -title: '', -platform: 'macos', -id: 23597 -} -windowInfo { -owner: { -processId: 516, -bundleId: 'com.apple.finder', -name: 'Finder', -path: '/System/Library/CoreServices/Finder.app' -}, -} - -Honestly, if any of the bundleIDs contain com.apple, it's probably not productive. - -Last, but not least, let's sign this App up for the Apple Developer Program. diff --git a/src/main/childProcess.ts b/src/main/childProcess.ts index 93cd0e3..39831c7 100644 --- a/src/main/childProcess.ts +++ b/src/main/childProcess.ts @@ -2,6 +2,13 @@ import fs from 'fs' import path from 'path' import plist from 'simple-plist' +interface MacosAppInfo { + CFBundleIdentifier: string + CFBundleName: string + CFBundleExecutable: string + CFBundleIconFile: string +} + // Helper to read the Info.plist file async function readPlistFile(filePath: string) { return new Promise((resolve, reject) => { @@ -22,7 +29,7 @@ async function readIcnsAsImageUri(file: string) { if (!buf) return '' const totalSize = buf.readInt32BE(4) - 8 - const icons = [] + const icons: { type: string; size: number; data: Buffer }[] = [] let start = 0 const buffer = buf.subarray(8) @@ -41,15 +48,13 @@ async function readIcnsAsImageUri(file: string) { } return '' // No valid image data } catch (error) { - console.error(`Error reading .icns file: ${error.message}`) + console.error(`Error reading .icns file: ${error}`) return '' // Return an empty string or fallback icon } } // Updated getInstalledApps function -export async function getInstalledApps(): Promise< - { name: string; path: string; icon: string | null }[] -> { +export async function getInstalledApps(): Promise<{ name: string; path: string; icon: string }[]> { const dir = '/Applications' const appPaths = await fs.promises.readdir(dir) @@ -61,7 +66,7 @@ export async function getInstalledApps(): Promise< const info = await readPlistFile(plistPath) const iconPath = path.join(fullAppPath, 'Contents/Resources', info.CFBundleIconFile) - let icon = null + let icon: string | null = null if (fs.existsSync(iconPath)) { icon = await readIcnsAsImageUri(iconPath) } else { @@ -82,5 +87,5 @@ export async function getInstalledApps(): Promise< }) const apps = await Promise.all(appPromises) - return apps.filter(Boolean) // Remove null results + return apps.filter(Boolean) as { name: string; path: string; icon: string }[] } diff --git a/src/main/index.ts b/src/main/index.ts index fe86905..4deb033 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,7 +10,6 @@ import Store from 'electron-store' import { StoreSchema, SiteTimeTracker, DeepWorkHours, MessageType, User } from './types' import { updateSiteTimeTracker } from './productivityUtils' import { getInstalledApps } from './childProcess' -import { get } from 'http' export interface TypedStore extends Store { get(key: K): StoreSchema[K] @@ -21,10 +20,9 @@ export interface TypedStore extends Store { } const store = new Store() as TypedStore -store.clear() let currentSiteTimeTrackers: SiteTimeTracker[] = [] let currentDeepWork = 0 -const deepWorkTarget = store.get('deepWorkTarget', 4) as number // Default to 4 hours if not set +const deepWorkTarget = store.get('deepWorkTarget', 4) as number let mainWindow: BrowserWindow | null = null setupEnvironment() @@ -140,8 +138,12 @@ function startActivityMonitoring() { try { const windowInfo = await activeWindow() if (windowInfo && windowInfo!.platform === 'macos') { - console.log('windowInfo', windowInfo) - updateSiteTimeTracker(windowInfo, currentSiteTimeTrackers) + if (!windowInfo.owner.bundleId.includes('com.apple')) { + console.log('windowInfo', windowInfo) + updateSiteTimeTracker(windowInfo, currentSiteTimeTrackers) + } else { + console.log('Ignoring Apple App', windowInfo.owner.bundleId) + } } } catch (error) { console.error('Error getting active window:', error) diff --git a/src/main/worker.ts b/src/main/worker.ts index bc9a2b6..ea32674 100644 --- a/src/main/worker.ts +++ b/src/main/worker.ts @@ -42,7 +42,6 @@ function updateDeepWorkHours(siteTrackers: SiteTimeTracker[], deepWorkHours: Dee return deepWorkHours } -//TODO: Add when logic is added in frontend + Determine if current activity is considered deep work function isDeepWork(item: string) { // You can customize this condition based on specific apps, sites, or window titles // Check if the tracker is a deep work app (e.g., VSCode, GitHub, etc.) @@ -111,19 +110,26 @@ async function persistDailyData( return } + const MIN_TIME_THRESHOLD = 60 + + // Filter out sites/apps with time spent less than the threshold + const filteredTrackers = workerSiteTimeTrackers.filter( + (tracker) => tracker.timeSpent >= MIN_TIME_THRESHOLD * 1000 // timeSpent is in milliseconds + ) + + if (filteredTrackers.length === 0) { + console.log('No site time trackers met the minimum time threshold to persist.') + return + } + const today = dayjs().format('dddd') // Get back Monday, Tuesday, etc. - const dailyData: { - username: string - url: string - title: string - timeSpent: number - date: string - }[] = workerSiteTimeTrackers.map((tracker) => ({ + + const dailyData = filteredTrackers.map((tracker) => ({ username: currentUsername, url: tracker.url, title: tracker.title, timeSpent: tracker.timeSpent, - date: dayjs().format('YYYY-MM-DD') // Get back 2023-09-27 + date: dayjs().format('YYYY-MM-DD') // Get back 2024-09-30 })) try { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 88468c0..04a9a1e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,7 +8,6 @@ import './assets/main.css' import logo from './assets/deepWork.svg' import { IconSettings } from './components/ui/icons' import { Button } from './components/ui/button' -import Onboarding from './Onboarding' // Lazy load the components const Login = lazy(() => import('./Login')) @@ -17,6 +16,8 @@ const Home = lazy(() => import('./Home')) const HelloWorld = () =>

Hello World!

const BarChart = lazy(() => import('./BarChart')) +const Onboarding = lazy(() => import('./Onboarding')) + const Settings = lazy(() => import('./Settings')) const App = (props: ComponentProps) => { diff --git a/src/renderer/src/BarChart.tsx b/src/renderer/src/BarChart.tsx index 5228840..a8e8799 100644 --- a/src/renderer/src/BarChart.tsx +++ b/src/renderer/src/BarChart.tsx @@ -10,6 +10,7 @@ import { Legend } from 'chart.js' import { Bar } from 'solid-chartjs' +import { Button } from './components/ui/button' const BarChart = () => { const [chartData, setChartData] = createSignal({ @@ -24,41 +25,41 @@ const BarChart = () => { } ] }) - onMount(() => { - ChartJS.register(BarController, CategoryScale, BarElement, LinearScale, Tooltip, Title, Legend) - // Send the initial request to fetch deep work data - window?.electron.ipcRenderer.send('fetch-deep-work-data') + let refreshIntervalId - // Listen for the deep work data response and update the chart - const deepWorkDataHandler = (event, data) => { - if (data && data.length) { - console.log('Retrieved Data! ', data) + const fetchDeepWorkData = () => { + window?.electron?.ipcRenderer.send('fetch-deep-work-data') + } - setChartData((prevData) => ({ - ...prevData, - datasets: [ - { - ...prevData.datasets[0], - data: data - } - ] - })) - } else { - console.log('No data found for deep work hours. Using default data.') - } + const handleDataResponse = (event, data) => { + if (data && data.length) { + console.log('Retrieved Data! ', data) + setChartData((prevData) => ({ + ...prevData, + datasets: [{ ...prevData.datasets[0], data }] + })) + } else { + console.log('No data found for deep work hours.') } + } - window?.electron.ipcRenderer.on('deep-work-data-response', deepWorkDataHandler) + onMount(() => { + ChartJS.register(BarController, CategoryScale, BarElement, LinearScale, Tooltip, Title, Legend) + fetchDeepWorkData() + + window?.electron.ipcRenderer.on('deep-work-data-response', handleDataResponse) + + refreshIntervalId = setInterval(() => fetchDeepWorkData(), 14400000) // Refresh every 4 hours - // Clean up the event listener on unmount onCleanup(() => { - window?.electron.ipcRenderer.removeListener('deep-work-data-response', deepWorkDataHandler) + window?.electron.ipcRenderer.removeListener('deep-work-data-response', handleDataResponse) + clearInterval(refreshIntervalId) }) }) const chartOptions = { - responsive: false, + responsive: true, maintainAspectRatio: true, scales: { y: { @@ -70,21 +71,25 @@ const BarChart = () => { } }, plugins: { - title: { - display: true, - text: 'Deep Work Hours' - }, + title: { display: true, text: 'Deep Work Hours' }, tooltip: { callbacks: { label: function (context) { - const value = context.raw - return value === 0 ? 'Data coming soon' : `${value} hours` + return context.raw === 0 ? 'Data coming soon' : `${context.raw} hours` } } } } } - return + + return ( +
+ + +
+ ) } export default BarChart diff --git a/src/renderer/src/CircularProgress.tsx b/src/renderer/src/CircularProgress.tsx new file mode 100644 index 0000000..4390760 --- /dev/null +++ b/src/renderer/src/CircularProgress.tsx @@ -0,0 +1,34 @@ +import { createEffect } from 'solid-js' +const CircularProgress = (props) => { + const radius = 45 + const circumference = 2 * Math.PI * radius + + console.log('Progress value inside CircularProgress:', props.progress) + createEffect(() => { + console.log('Progress value inside CircularProgress createEffect:', props.progress) + }) + + return ( +
+ + + + +
+

Progress: {Math.round(props.progress * 100)}%

+
+
+ ) +} + +export default CircularProgress diff --git a/src/renderer/src/Home.tsx b/src/renderer/src/Home.tsx index 20e9834..ebb4f8d 100644 --- a/src/renderer/src/Home.tsx +++ b/src/renderer/src/Home.tsx @@ -1,83 +1,63 @@ -import { onMount } from 'solid-js' -import { gsap } from 'gsap' -import logo from './assets/deepWork.svg' // Your logo path +import { onMount, createSignal, onCleanup, createEffect } from 'solid-js' +import { useAuth } from './lib/AuthContext' +import User from './types' +import CircularProgress from './CircularProgress' +import SandTimer from './SandTimer' +import dayjs from 'dayjs' const Home = () => { - let logoRef - let particleContainerRef + const [loggedIn, setIsLoggedIn] = useAuth() + const user = (JSON.parse(localStorage.getItem('user') || '') as User) || {} + const [progress, setProgress] = createSignal(0) + const [deepWorkDone, setDeepWorkDone] = createSignal(0) // Changed to signal for reactivity - // Function to create and drop particles - const createParticles = () => { - const numberOfParticles = 100 // Number of sand particles + onMount(() => { + fetchDeepWorkData() + window?.electron.ipcRenderer.on('deep-work-data-response', handleDataResponse) - for (let i = 0; i < numberOfParticles; i++) { - const particle = document.createElement('div') - particle.classList.add('sand-particle') - particleContainerRef.appendChild(particle) + // Cleanup the listener properly using onCleanup inside onMount + return () => { + window?.electron?.ipcRenderer.removeListener('deep-work-data-response', handleDataResponse) + } + }) - const delay = i * 0.3 // Delay for each particle to create the falling effect - const duration = 5 + Math.random() * 5 // Randomize the speed of each particle + createEffect(() => { + console.log('Updated progress:', progress()) + }) - gsap.fromTo( - particle, - { - x: Math.random() * 50 - 25, // Random horizontal position within the top part - y: 0, - opacity: 1 - }, - { - y: 150, // Fall to the bottom of the hourglass - opacity: 0.9, - duration, - ease: 'power1.in', - delay - } - ) - } + const fetchDeepWorkData = () => { + window?.electron?.ipcRenderer.send('fetch-deep-work-data') } - onMount(() => { - createParticles() - }) + const handleDataResponse = (event, data) => { + const todayIndex = dayjs().day() - 1 // dayjs starts at 1 + if (data && data.length) { + const workDone = data[todayIndex] + setDeepWorkDone(workDone) + const dailyTarget = 4 + console.log('deepWorkDone', workDone) + setProgress(workDone / dailyTarget) + } else { + console.log('No data found for deep work hours.') + } + } return ( -
-

Welcome to Deep Focus

-
-
- (logoRef = el)} - src={logo} - alt="Deep Focus Logo" - style={{ width: '100%', height: '100%' }} - /> - - {/* Particle container */} -
(particleContainerRef = el)} - style={{ - position: 'absolute', - top: '0', - left: '65%', - transform: 'translateX(-50%)', - width: '50%', - height: '100%', - overflow: 'hidden' // Ensure particles don't overflow - }} - >
+
+ {!loggedIn() && !user ? ( +
+

Welcome to Deep Focus

- - {/* Additional CSS to style the particles */} - -
+ ) : ( +
+

Welcome back {user.firstName}!

+
+ +

Tip: Take a break every 50 minutes to improve efficiency!

+
+
+ )} + {!loggedIn() && }
) } diff --git a/src/renderer/src/Onboarding.tsx b/src/renderer/src/Onboarding.tsx index 37cdb89..1cd26f6 100644 --- a/src/renderer/src/Onboarding.tsx +++ b/src/renderer/src/Onboarding.tsx @@ -1,4 +1,4 @@ -import { createSignal, For } from 'solid-js' +import { createSignal } from 'solid-js' import UnproductiveApps from './UnproductiveApps' import UnproductiveWebsites from './UnproductiveWebsites' import { Button } from './components/ui/button' @@ -6,8 +6,6 @@ import { useNavigate } from '@solidjs/router' const Onboarding = () => { const [step, setStep] = createSignal(1) - const [unproductiveSites, setUnproductiveSites] = createSignal([]) - const [unproductiveApps, setUnproductiveApps] = createSignal([]) const navigate = useNavigate() const handleNext = () => { @@ -19,18 +17,8 @@ const Onboarding = () => { } return (
- {step() === 1 && ( - - )} - {step() === 2 && ( - - )} + {step() === 1 && } + {step() === 2 && } {' '} diff --git a/src/renderer/src/SandTimer.tsx b/src/renderer/src/SandTimer.tsx new file mode 100644 index 0000000..64c033e --- /dev/null +++ b/src/renderer/src/SandTimer.tsx @@ -0,0 +1,81 @@ +import { onMount } from 'solid-js' +import { gsap } from 'gsap' +import logo from './assets/deepWork.svg' + +const SandTimer = () => { + let logoRef + let particleContainerRef + + // Function to create and drop particles + const createParticles = () => { + const numberOfParticles = 100 // Number of sand particles + + for (let i = 0; i < numberOfParticles; i++) { + const particle = document.createElement('div') + particle.classList.add('sand-particle') + particleContainerRef.appendChild(particle) + + const delay = i * 0.3 // Delay for each particle to create the falling effect + const duration = 5 + Math.random() * 5 // Randomize the speed of each particle + + gsap.fromTo( + particle, + { + x: Math.random() * 50 - 25, // Random horizontal position within the top part + y: 0, + opacity: 1 + }, + { + y: 150, // Fall to the bottom of the hourglass + opacity: 0.9, + duration, + ease: 'power1.in', + delay + } + ) + } + } + + onMount(() => { + createParticles() + }) + + return ( +
+
+ (logoRef = el)} + src={logo} + alt="Deep Focus Logo" + style={{ width: '100%', height: '100%' }} + /> + + {/* Particle container */} +
(particleContainerRef = el)} + style={{ + position: 'absolute', + top: '0', + left: '65%', + transform: 'translateX(-50%)', + width: '50%', + height: '100%', + overflow: 'hidden' // Ensure particles don't overflow + }} + >
+
+ + +
+ ) +} + +export default SandTimer diff --git a/src/renderer/src/Settings.tsx b/src/renderer/src/Settings.tsx index 396c5fb..e919881 100644 --- a/src/renderer/src/Settings.tsx +++ b/src/renderer/src/Settings.tsx @@ -1,37 +1,12 @@ import { createSignal, onCleanup, onMount } from 'solid-js' import { Button } from './components/ui/button' import { TextField, TextFieldInput, TextFieldLabel } from './components/ui/text-field' +import UnproductiveWebsites from './UnproductiveWebsites' +import UnproductiveApps from './UnproductiveApps' const Settings = () => { - const [unproductiveUrls, setUnproductiveUrls] = createSignal([]) - const [newUrl, setNewUrl] = createSignal('') - - onMount(() => { - window?.electron.ipcRenderer.send('fetch-unproductive-urls') // Request URLs from main process - window?.electron.ipcRenderer.on('unproductive-urls-response', (event, urls) => { - setUnproductiveUrls(urls || []) // Update the state with the received URLs - console.log('Unproductive URLs received from main process:', urls, event.processId) - }) - - onCleanup(() => { - window?.electron.ipcRenderer.removeAllListeners('unproductive-urls-response') - }) - }) - - const handleAddUrl = () => { - if (newUrl().trim()) { - setUnproductiveUrls([...unproductiveUrls(), newUrl().trim()]) - setNewUrl('') - } - console.log('Unproductive URLs updated:', unproductiveUrls()) - window?.electron.ipcRenderer.send('add-unproductive-url', unproductiveUrls()) - } - - const handleRemoveUrl = (url: string) => { - const updatedUrls = unproductiveUrls().filter((item) => item !== url) - setUnproductiveUrls(updatedUrls) - window?.electron.ipcRenderer.send('remove-unproductive-url', unproductiveUrls()) - } + const [showEditWebsites, setShowEditWebsites] = createSignal(false) + const [showEditApps, setShowEditApps] = createSignal(false) return (
@@ -39,31 +14,22 @@ const Settings = () => {

Unproductive Websites

-
    - {unproductiveUrls().map((url) => ( -
  • - {url} - -
  • - ))} -
-
- - Unproductive Websites - setNewUrl(e.currentTarget.value)} - /> - - -
+ + + {showEditWebsites() && } +
+ +
+

Unproductive Apps

+ + + + {showEditApps() && }
) diff --git a/src/renderer/src/UnproductiveApps.tsx b/src/renderer/src/UnproductiveApps.tsx index b81316e..6eab78d 100644 --- a/src/renderer/src/UnproductiveApps.tsx +++ b/src/renderer/src/UnproductiveApps.tsx @@ -2,7 +2,7 @@ import { createSignal, For, onMount, onCleanup } from 'solid-js' import { Button } from './components/ui/button' const UnproductiveApps = () => { - const [apps, setApps] = createSignal([]) + const [apps, setApps] = createSignal<{ name: string; path: string; icon: string }[]>([]) const [unproductiveApps, setUnproductiveApps] = createSignal([]) // List of apps marked as unproductive // Fetch stored unproductive apps from Electron store on mount @@ -30,17 +30,16 @@ const UnproductiveApps = () => { }) }) - // Toggle unproductive apps and persist them const toggleUnproductive = (app) => { - setUnproductiveApps((prev) => { - const updatedApps = prev.includes(app) - ? prev.filter((unproductiveApp) => unproductiveApp !== app) // Remove from unproductive apps - : [...prev, app] // Add to unproductive apps - - // Persist updated unproductive apps to Electron store - window.electron.ipcRenderer.send('update-unproductive-apps', updatedApps) - return updatedApps - }) + const getUpdatedUnproductiveApps = (prevApps) => { + return prevApps.includes(app) + ? prevApps.filter((unproductiveApp) => unproductiveApp !== app) + : [...prevApps, app] + } + const updatedUnproductiveApps = getUpdatedUnproductiveApps(unproductiveApps) + setUnproductiveApps(updatedUnproductiveApps) + window.electron.ipcRenderer.send('update-unproductive-apps', updatedUnproductiveApps) + return updatedUnproductiveApps } const fetchApps = () => { diff --git a/src/renderer/src/UnproductiveWebsites.tsx b/src/renderer/src/UnproductiveWebsites.tsx index 2d38ef7..5916b82 100644 --- a/src/renderer/src/UnproductiveWebsites.tsx +++ b/src/renderer/src/UnproductiveWebsites.tsx @@ -2,7 +2,7 @@ import { createSignal, For, onMount, onCleanup } from 'solid-js' import { TextField, TextFieldLabel, TextFieldInput } from './components/ui/text-field' import { Button } from './components/ui/button' -const UnproductiveWebsites = () => { +const UnproductiveWebsites = (props: {}) => { const [site, setSite] = createSignal('') const [unproductiveSites, setUnproductiveSites] = createSignal([])