add initial idle detection implementation

This commit is contained in:
Gregor Vostrak
2025-11-19 17:45:51 +01:00
parent d4dd71b120
commit 1dc3100ac7
30 changed files with 3081 additions and 913 deletions
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/main/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
})
@@ -0,0 +1,7 @@
CREATE TABLE `activity_periods` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`start` text NOT NULL,
`end` text NOT NULL,
`is_idle` integer NOT NULL,
`created_at` text NOT NULL
);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` text NOT NULL,
UNIQUE(`key`)
);
-- Insert default settings
INSERT INTO `settings` (`key`, `value`, `updated_at`) VALUES
('widget_activated', 'true', datetime('now')),
('tray_timer_activated', 'true', datetime('now')),
('idle_detection_enabled', 'true', datetime('now')),
('idle_threshold_minutes', '5', datetime('now'));
+63
View File
@@ -0,0 +1,63 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ff15e91e-a2a5-4ab4-94ee-2f1d1ae08f62",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"activity_periods": {
"name": "activity_periods",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"start": {
"name": "start",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end": {
"name": "end",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_idle": {
"name": "is_idle",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+107
View File
@@ -0,0 +1,107 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"prevId": "ff15e91e-a2a5-4ab4-94ee-2f1d1ae08f62",
"tables": {
"activity_periods": {
"name": "activity_periods",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"start": {
"name": "start",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end": {
"name": "end",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_idle": {
"name": "is_idle",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"settings_key_unique": {
"name": "settings_key_unique",
"columns": ["key"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1763389706823,
"tag": "0000_create_activity_periods_table",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1731945600000,
"tag": "0001_add_settings_table",
"breakpoints": true
}
]
}
+1403 -661
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -32,11 +32,16 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@sentry/electron": "^5.3.0",
"@sentry/vite-plugin": "^2.22.2",
"@solidtime/api": "^0.0.4",
"@solidtime/ui": "^0.0.11",
"electron-updater": "^6.6.2"
"@libsql/client": "^0.15.15",
"@sentry/electron": "^5.7.0",
"@sentry/vite-plugin": "^2.22.8",
"@solidtime/api": "^0.0.5",
"@solidtime/ui": "^0.0.13",
"drizzle-kit": "^0.31.7",
"drizzle-orm": "^0.44.7",
"electron-updater": "^6.6.2",
"pinia": "^3.0.3",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
@@ -55,11 +60,11 @@
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/tsconfig": "^0.5.1",
"@vueuse/core": "^10.11.0",
"@vueuse/core": "^12.5.0",
"@zodios/core": "^10.9.6",
"autoprefixer": "^10.4.19",
"dayjs": "^1.11.11",
"electron": "^38.4.0",
"electron": "^38.5.0",
"electron-builder": "^26.0.12",
"electron-builder-notarize": "^1.5.2",
"electron-devtools-installer": "^4.0.0",
@@ -70,8 +75,9 @@
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.5.2",
"vite": "^5.3.1",
"vite": "^6.1.7",
"vue": "^3.4.30",
"vue-tsc": "^2.0.22",
"zod-validation-error": "^3.3.1"
+132
View File
@@ -0,0 +1,132 @@
import { ipcMain } from 'electron'
import { db } from './db/client'
import { activityPeriods } from './db/schema'
import { gte, lte, and } from 'drizzle-orm'
import * as Sentry from '@sentry/electron/main'
import { getCurrentActivityPeriod } from './idleMonitor'
// Type definitions for activity period responses
interface ActivityPeriodResponse {
start: string
end: string
isIdle: boolean
}
interface ActivityPeriodsResult {
success: boolean
data?: ActivityPeriodResponse[]
error?: string
}
// Helper function to validate ISO date strings with strict UTC format
function isValidISODate(dateString: unknown): dateString is string {
if (typeof dateString !== 'string' || dateString.length === 0) {
return false
}
// Check for ISO 8601 format with 'T' separator
if (!dateString.includes('T')) {
return false
}
const date = new Date(dateString)
if (!(date instanceof Date) || isNaN(date.getTime())) {
return false
}
// Verify the string can be parsed back to the same value
return date.toISOString() !== 'Invalid Date'
}
/**
* Fetches activity periods from the database for a given date range
*/
async function getActivityPeriods(
startDate: unknown,
endDate: unknown
): Promise<ActivityPeriodsResult> {
try {
// Validate input types and formats
if (!isValidISODate(startDate)) {
const error =
'Invalid startDate format. Expected ISO 8601 format with timezone (e.g., 2024-01-01T00:00:00.000Z)'
console.error(error, { startDate })
return { success: false, error }
}
if (!isValidISODate(endDate)) {
const error =
'Invalid endDate format. Expected ISO 8601 format with timezone (e.g., 2024-01-01T23:59:59.999Z)'
console.error(error, { endDate })
return { success: false, error }
}
// Ensure start is before or equal to end
const startDateTime = new Date(startDate).getTime()
const endDateTime = new Date(endDate).getTime()
if (startDateTime > endDateTime) {
const error = 'Start date must be before or equal to end date'
console.error(error, { startDate, endDate })
return { success: false, error }
}
const periods = await db
.select()
.from(activityPeriods)
.where(and(gte(activityPeriods.start, startDate), lte(activityPeriods.end, endDate)))
.orderBy(activityPeriods.start)
// Validate returned data structure
const validatedPeriods: ActivityPeriodResponse[] = periods.map((period) => {
if (!isValidISODate(period.start) || !isValidISODate(period.end)) {
throw new Error(`Invalid date format in database record: ${JSON.stringify(period)}`)
}
return {
start: period.start,
end: period.end,
isIdle: Boolean(period.isIdle),
}
})
// Include the current ongoing activity period if it exists and overlaps with the requested range
const currentPeriod = getCurrentActivityPeriod()
if (currentPeriod) {
const currentStart = new Date(currentPeriod.start).getTime()
const currentEnd = new Date(currentPeriod.end).getTime()
// Check if current period overlaps with requested date range
if (currentEnd >= startDateTime && currentStart <= endDateTime) {
validatedPeriods.push({
start: currentPeriod.start,
end: currentPeriod.end,
isIdle: currentPeriod.isIdle,
})
}
}
return { success: true, data: validatedPeriods }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
console.error('Failed to fetch activity periods:', errorMessage, error)
Sentry.captureException(error, {
tags: { context: 'getActivityPeriods' },
extra: { startDate, endDate },
})
return { success: false, error: errorMessage }
}
}
/**
* Registers IPC handlers for activity periods
*/
export function registerActivityPeriodListeners(): void {
// IPC handler to fetch activity periods for a date range
ipcMain.handle(
'getActivityPeriods',
async (_event, startDate: unknown, endDate: unknown): Promise<ActivityPeriodsResult> => {
return getActivityPeriods(startDate, endDate)
}
)
}
+18
View File
@@ -0,0 +1,18 @@
import { drizzle } from 'drizzle-orm/libsql'
import { createClient } from '@libsql/client'
import { app } from 'electron'
import path from 'path'
import * as schema from './schema'
// Create the database in the user data directory
const dbPath = path.join(app.getPath('userData'), 'solidtime.db')
// Create libsql client using file:// protocol for local SQLite
const client = createClient({
url: `file:${dbPath}`
})
export const db = drizzle(client, { schema })
// Export the client for any raw queries if needed
export { client }
+36
View File
@@ -0,0 +1,36 @@
import { migrate } from 'drizzle-orm/libsql/migrator'
import { db } from './client'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
export async function runMigrations(): Promise<void> {
try {
// Determine the correct migrations folder path
// In production (packaged app), migrations are bundled with the app
// In development, they're in the project root
let migrationsFolder: string
if (app.isPackaged) {
// Production: migrations are in the app.asar or extraResources
migrationsFolder = path.join(process.resourcesPath, 'drizzle')
} else {
// Development: migrations are relative to the source
migrationsFolder = path.join(__dirname, '../../drizzle')
}
// Verify the migrations folder exists
if (!fs.existsSync(migrationsFolder)) {
throw new Error(`Migrations folder not found at: ${migrationsFolder}`)
}
console.log('Running migrations from:', migrationsFolder)
await migrate(db, { migrationsFolder })
console.log('Migrations completed successfully')
} catch (error) {
console.error('Migration failed:', error)
throw error
}
}
+100
View File
@@ -0,0 +1,100 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
/**
* Validates that a string is a proper UTC ISO 8601 timestamp
* Expected format: YYYY-MM-DDTHH:mm:ss.sssZ
*/
export function isValidUTCTimestamp(timestamp: string): boolean {
if (typeof timestamp !== 'string' || timestamp.length === 0) {
return false
}
// Must contain 'T' separator and end with 'Z' for UTC
if (!timestamp.includes('T') || !timestamp.endsWith('Z')) {
return false
}
// Parse and validate the date
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
return false
}
// Verify it round-trips correctly (prevents partial dates)
const isoString = date.toISOString()
return (
isoString === timestamp || Math.abs(new Date(isoString).getTime() - date.getTime()) < 1000
)
}
/**
* Ensures a timestamp is in UTC format, converting if necessary
*/
export function ensureUTCTimestamp(timestamp: string | Date): string {
if (timestamp instanceof Date) {
return timestamp.toISOString()
}
if (!isValidUTCTimestamp(timestamp)) {
// Try to parse and convert to UTC
const date = new Date(timestamp)
if (isNaN(date.getTime())) {
throw new Error(`Invalid timestamp: ${timestamp}`)
}
return date.toISOString()
}
return timestamp
}
export const activityPeriods = sqliteTable('activity_periods', {
id: integer('id').primaryKey({ autoIncrement: true }),
start: text('start').notNull(), // ISO 8601 UTC timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)
end: text('end').notNull(), // ISO 8601 UTC timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)
isIdle: integer('is_idle', { mode: 'boolean' }).notNull(), // true for idle, false for active
createdAt: text('created_at')
.notNull()
.$defaultFn(() => new Date().toISOString()),
})
export type ActivityPeriod = typeof activityPeriods.$inferSelect
export type NewActivityPeriod = typeof activityPeriods.$inferInsert
/**
* Validates a NewActivityPeriod object before insertion
* Ensures all timestamps are in proper UTC format
*/
export function validateNewActivityPeriod(period: NewActivityPeriod): void {
if (!isValidUTCTimestamp(period.start)) {
throw new Error(`Invalid start timestamp format. Expected UTC ISO 8601: ${period.start}`)
}
if (!isValidUTCTimestamp(period.end)) {
throw new Error(`Invalid end timestamp format. Expected UTC ISO 8601: ${period.end}`)
}
// Validate that start is before end
const startTime = new Date(period.start).getTime()
const endTime = new Date(period.end).getTime()
if (startTime >= endTime) {
throw new Error(
`Start time must be before end time. Start: ${period.start}, End: ${period.end}`
)
}
}
/**
* Settings table for storing application configuration
*/
export const settings = sqliteTable('settings', {
id: integer('id').primaryKey({ autoIncrement: true }),
key: text('key').notNull().unique(),
value: text('value').notNull(),
updatedAt: text('updated_at')
.notNull()
.$defaultFn(() => new Date().toISOString()),
})
export type Setting = typeof settings.$inferSelect
export type NewSetting = typeof settings.$inferInsert
+314
View File
@@ -0,0 +1,314 @@
import { powerMonitor, ipcMain, dialog } from 'electron'
import { getMainWindow } from './mainWindow'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import duration from 'dayjs/plugin/duration'
import type { Dayjs } from 'dayjs'
import { db } from './db/client'
import { activityPeriods, validateNewActivityPeriod, ensureUTCTimestamp } from './db/schema'
import { getAppSettings } from './settings'
// Configure dayjs for main process
dayjs.extend(utc)
dayjs.extend(duration)
// Helper functions for formatting (replicate UI package functionality for main process)
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`
} else if (minutes > 0) {
return `${minutes}m ${secs}s`
} else {
return `${secs}s`
}
}
function formatTime(isoString: string): string {
return dayjs(isoString).format('HH:mm:ss')
}
let idleCheckInterval: NodeJS.Timeout | null = null
let isIdle = false
let idleStartTime: Dayjs | null = null
let activeStartTime: Dayjs | null = null
let idleThreshold = 300
let idleDetectionEnabled = true
let isTimerRunning = false
let waitingForUserResponse = false // Track if we're waiting for idle dialog response
export async function initializeIdleMonitor() {
// Load settings from database
const appSettings = await getAppSettings()
idleThreshold = appSettings.idleThresholdMinutes * 60 // Convert to seconds
idleDetectionEnabled = appSettings.idleDetectionEnabled
console.log('Idle monitor initialized with settings:', {
idleThreshold,
idleDetectionEnabled,
})
registerIdleMonitorListeners()
// Start monitoring if idle detection is enabled (regardless of timer state)
if (idleDetectionEnabled) {
startIdleMonitoring()
}
}
function registerIdleMonitorListeners() {
// Listen for idle threshold updates from renderer
ipcMain.on('updateIdleThreshold', (_event, thresholdMinutes: number) => {
if (typeof thresholdMinutes === 'number' && thresholdMinutes > 0) {
idleThreshold = thresholdMinutes * 60 // Convert minutes to seconds
console.log('Idle threshold updated to:', idleThreshold, 'seconds')
} else {
console.warn('Invalid idle threshold value:', thresholdMinutes)
}
})
// Listen for idle detection enabled/disabled from renderer
ipcMain.on('updateIdleDetectionEnabled', (_event, enabled: boolean) => {
console.log('Idle detection enabled:', enabled, idleThreshold)
idleDetectionEnabled = enabled
if (!enabled && idleCheckInterval) {
stopIdleMonitoring()
} else if (enabled && !idleCheckInterval) {
startIdleMonitoring()
}
})
// Listen for timer state changes
ipcMain.on('timerStateChanged', (_event, running: boolean) => {
isTimerRunning = running
console.log('Timer state changed:', running)
})
}
function startIdleMonitoring() {
if (idleCheckInterval) {
console.log('Idle monitoring already running, skipping start')
return // Already monitoring
}
console.log('Starting idle monitoring')
isIdle = false
idleStartTime = null
// Check current idle state immediately to set correct initial state
const currentIdleTime = powerMonitor.getSystemIdleTime()
if (currentIdleTime >= idleThreshold) {
// System is already idle when monitoring starts
isIdle = true
const now = dayjs()
idleStartTime = now.subtract(currentIdleTime, 'seconds')
activeStartTime = null
console.log(
`System already idle when monitoring started. Idle since: ${idleStartTime.toISOString()}`
)
} else {
// System is active, start tracking from now
activeStartTime = dayjs()
}
// Check idle state every second
idleCheckInterval = setInterval(() => {
const idleTime = powerMonitor.getSystemIdleTime()
if (idleTime >= idleThreshold) {
// System has been idle for longer than threshold
if (!isIdle) {
// Transition to idle state
isIdle = true
const now = dayjs()
idleStartTime = now.subtract(idleTime, 'seconds')
console.log(`System became idle at ${idleStartTime.toISOString()}`)
// Save the active period that just ended
if (activeStartTime) {
// Ensure the end time is not before the start time due to timing precision
const endTime = idleStartTime.isBefore(activeStartTime)
? activeStartTime
: idleStartTime
saveActivityPeriod(
activeStartTime.utc().format(),
endTime.utc().format(),
false
)
activeStartTime = null
}
}
} else {
// System is active
if (isIdle && idleStartTime) {
// Transition from idle to active
const idleEnd = dayjs()
const idleDurationSeconds = idleEnd.diff(idleStartTime, 'seconds')
console.log(
`System became active at ${idleEnd.toISOString()}, idle duration: ${idleDurationSeconds}s`
)
// Capture the idle period info before resetting state
const capturedIdleStart = idleStartTime.utc().format()
const capturedIdleEnd = idleEnd.utc().format()
const capturedDuration = idleDurationSeconds
// Reset idle state and resume activity tracking immediately
isIdle = false
idleStartTime = null
activeStartTime = idleEnd
// Only show dialog if timer is running and we're not already waiting for a response
// This prevents multiple dialogs from appearing
if (isTimerRunning && !waitingForUserResponse) {
waitingForUserResponse = true
// Show dialog asynchronously without blocking the interval
showIdleDialog(capturedIdleStart, capturedIdleEnd, capturedDuration)
.then(() => {
waitingForUserResponse = false
})
.catch((error) => {
console.error('Error showing idle dialog:', error)
waitingForUserResponse = false
})
} else if (!isTimerRunning) {
// If timer is not running, just save the idle period automatically
saveActivityPeriod(capturedIdleStart, capturedIdleEnd, true)
}
}
}
}, 1000)
}
async function saveActivityPeriod(start: string, end: string, isIdlePeriod: boolean) {
try {
// Ensure timestamps are in proper UTC format
const utcStart = ensureUTCTimestamp(start)
const utcEnd = ensureUTCTimestamp(end)
const newPeriod = {
start: utcStart,
end: utcEnd,
isIdle: isIdlePeriod,
}
// Validate the period before insertion
validateNewActivityPeriod(newPeriod)
await db.insert(activityPeriods).values(newPeriod)
console.log(`Saved ${isIdlePeriod ? 'idle' : 'active'} period: ${utcStart} to ${utcEnd}`)
} catch (error) {
console.error('Failed to save activity period:', error)
// Log detailed error for debugging
if (error instanceof Error) {
console.error('Error details:', error.message)
}
}
}
async function showIdleDialog(idleStartTime: string, idleEndTime: string, durationSeconds: number) {
const mainWindow = getMainWindow()
if (!mainWindow) {
return
}
const formattedDuration = formatDuration(durationSeconds)
const startTime = formatTime(idleStartTime)
const endTime = formatTime(idleEndTime)
// Focus the main window to ensure dialog appears on top
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.focus()
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
title: 'Idle Time Detected',
message: 'You were away from your computer',
detail: `Idle Duration: ${formattedDuration}\nIdle Start: ${startTime}\nActivity Resumed: ${endTime}\n\nWhat would you like to do with the idle time?`,
buttons: ['Keep Idle Time', 'Discard Idle Time', 'Discard & Start New Timer'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
// Handle the user's choice
if (result.response === 0) {
// Keep Idle Time - save the idle period
await saveActivityPeriod(idleStartTime, idleEndTime, true)
} else if (result.response === 1) {
// Discard Idle Time - don't save anything
console.log('User discarded idle time')
} else if (result.response === 2) {
// Discard & Start New Timer - don't save idle time
console.log('User discarded idle time and will start new timer')
}
// Send the user's choice to renderer
mainWindow.webContents.send('idleDialogResponse', {
choice: result.response,
idleStartTime,
idleEndTime,
})
}
async function stopIdleMonitoring() {
// Save the current active period if we're stopping while active
if (activeStartTime && !isIdle) {
const now = dayjs()
await saveActivityPeriod(activeStartTime.toISOString(), now.toISOString(), false)
}
// Save current idle period if we're stopping while idle
if (idleStartTime && isIdle) {
const now = dayjs()
await saveActivityPeriod(idleStartTime.toISOString(), now.toISOString(), true)
}
if (idleCheckInterval) {
clearInterval(idleCheckInterval)
idleCheckInterval = null
}
isIdle = false
idleStartTime = null
activeStartTime = null
waitingForUserResponse = false
}
/**
* Gets the current ongoing activity period (not yet saved to database)
* Returns null if there's no ongoing period or if waiting for user response
*/
export function getCurrentActivityPeriod(): { start: string; end: string; isIdle: boolean } | null {
const now = dayjs()
if (isIdle && idleStartTime) {
// Currently in an idle period
return {
start: idleStartTime.utc().format(),
end: now.utc().format(),
isIdle: true,
}
} else if (!isIdle && activeStartTime) {
// Currently in an active period
return {
start: activeStartTime.utc().format(),
end: now.utc().format(),
isIdle: false,
}
}
return null
}
export { startIdleMonitoring, stopIdleMonitoring }
+38 -2
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { app, BrowserWindow, ipcMain, dialog } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/linux_icon.png?asset'
@@ -8,6 +8,10 @@ import { initializeMainWindow, registerMainWindowListeners } from './mainWindow'
import { initializeMiniWindow, registerMiniWindowListeners } from './miniWindow'
import { registerDeeplinkListeners } from './deeplink'
import { registerVueDevTools } from './devtools'
import { initializeIdleMonitor } from './idleMonitor'
import { runMigrations } from './db/migrate'
import { registerActivityPeriodListeners } from './activityPeriods'
import { registerSettingsListeners } from './settings'
import * as Sentry from '@sentry/electron/main'
import path from 'node:path'
@@ -59,12 +63,39 @@ function createWindow(): void {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
app.whenReady().then(async () => {
registerVueDevTools()
// Set app user model id for windows
electronApp.setAppUserModelId('solidtime.desktop')
// Run database migrations first
try {
await runMigrations()
console.log('Database migrations completed successfully')
} catch (error) {
console.error('Failed to run migrations:', error)
// Show error dialog to user
const { response } = await dialog.showMessageBox({
type: 'error',
title: 'Database Initialization Failed',
message: 'The application database could not be initialized.',
detail: 'This may be due to a corrupted database or insufficient permissions. Would you like to continue anyway? (Some features may not work correctly)',
buttons: ['Quit Application', 'Continue Anyway'],
defaultId: 0,
cancelId: 0,
})
if (response === 0) {
// User chose to quit
app.quit()
return
}
// If response === 1, continue but log warning
console.warn('User chose to continue despite migration failure')
}
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -75,7 +106,12 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
// Register IPC handlers
registerActivityPeriodListeners()
registerSettingsListeners()
createWindow()
await initializeIdleMonitor()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
+9
View File
@@ -1,6 +1,12 @@
import { join } from 'path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
let mainWindowInstance: BrowserWindow | null = null
export function getMainWindow(): BrowserWindow | null {
return mainWindowInstance
}
export function initializeMainWindow(icon: string) {
const mainWindow = new BrowserWindow({
width: 800,
@@ -16,6 +22,8 @@ export function initializeMainWindow(icon: string) {
webPreferences: {
preload: join(__dirname, '../preload/main.mjs'),
sandbox: false,
// The vite dev server causes CORS issues, so we disable webSecurity in development mode
webSecurity: process.env.NODE_ENV !== 'development',
},
})
@@ -39,6 +47,7 @@ export function initializeMainWindow(icon: string) {
mainWindow.show()
})
mainWindowInstance = mainWindow
return mainWindow
}
+211
View File
@@ -0,0 +1,211 @@
import { ipcMain } from 'electron'
import { db } from './db/client'
import { settings } from './db/schema'
import { eq } from 'drizzle-orm'
import * as Sentry from '@sentry/electron/main'
// Type definitions for settings
export interface AppSettings {
widgetActivated: boolean
trayTimerActivated: boolean
idleDetectionEnabled: boolean
idleThresholdMinutes: number
}
// Default settings
export const DEFAULT_SETTINGS: AppSettings = {
widgetActivated: true,
trayTimerActivated: true,
idleDetectionEnabled: true,
idleThresholdMinutes: 5,
}
// Setting keys used in the database
const SETTING_KEYS = {
WIDGET_ACTIVATED: 'widget_activated',
TRAY_TIMER_ACTIVATED: 'tray_timer_activated',
IDLE_DETECTION_ENABLED: 'idle_detection_enabled',
IDLE_THRESHOLD_MINUTES: 'idle_threshold_minutes',
} as const
/**
* Gets a setting value from the database
*/
async function getSetting(key: string): Promise<string | null> {
try {
const result = await db
.select()
.from(settings)
.where(eq(settings.key, key))
.limit(1)
return result[0]?.value ?? null
} catch (error) {
console.error(`Failed to get setting: ${key}`, error)
Sentry.captureException(error, {
tags: { context: 'getSetting' },
extra: { key },
})
return null
}
}
/**
* Sets a setting value in the database
*/
async function setSetting(key: string, value: string): Promise<void> {
try {
const existing = await getSetting(key)
if (existing !== null) {
// Update existing setting
await db
.update(settings)
.set({
value,
updatedAt: new Date().toISOString(),
})
.where(eq(settings.key, key))
} else {
// Insert new setting
await db.insert(settings).values({
key,
value,
updatedAt: new Date().toISOString(),
})
}
} catch (error) {
console.error(`Failed to set setting: ${key}`, error)
Sentry.captureException(error, {
tags: { context: 'setSetting' },
extra: { key, value },
})
throw error
}
}
/**
* Gets all application settings from the database
*/
export async function getAppSettings(): Promise<AppSettings> {
try {
const [widgetActivated, trayTimerActivated, idleDetectionEnabled, idleThresholdMinutes] =
await Promise.all([
getSetting(SETTING_KEYS.WIDGET_ACTIVATED),
getSetting(SETTING_KEYS.TRAY_TIMER_ACTIVATED),
getSetting(SETTING_KEYS.IDLE_DETECTION_ENABLED),
getSetting(SETTING_KEYS.IDLE_THRESHOLD_MINUTES),
])
return {
widgetActivated:
widgetActivated !== null
? widgetActivated === 'true'
: DEFAULT_SETTINGS.widgetActivated,
trayTimerActivated:
trayTimerActivated !== null
? trayTimerActivated === 'true'
: DEFAULT_SETTINGS.trayTimerActivated,
idleDetectionEnabled:
idleDetectionEnabled !== null
? idleDetectionEnabled === 'true'
: DEFAULT_SETTINGS.idleDetectionEnabled,
idleThresholdMinutes:
idleThresholdMinutes !== null
? parseInt(idleThresholdMinutes, 10)
: DEFAULT_SETTINGS.idleThresholdMinutes,
}
} catch (error) {
console.error('Failed to get app settings, using defaults:', error)
Sentry.captureException(error, {
tags: { context: 'getAppSettings' },
})
return DEFAULT_SETTINGS
}
}
/**
* Updates application settings in the database
*/
export async function updateAppSettings(
partialSettings: Partial<AppSettings>
): Promise<AppSettings> {
try {
const promises: Promise<void>[] = []
if (partialSettings.widgetActivated !== undefined) {
promises.push(
setSetting(
SETTING_KEYS.WIDGET_ACTIVATED,
String(partialSettings.widgetActivated)
)
)
}
if (partialSettings.trayTimerActivated !== undefined) {
promises.push(
setSetting(
SETTING_KEYS.TRAY_TIMER_ACTIVATED,
String(partialSettings.trayTimerActivated)
)
)
}
if (partialSettings.idleDetectionEnabled !== undefined) {
promises.push(
setSetting(
SETTING_KEYS.IDLE_DETECTION_ENABLED,
String(partialSettings.idleDetectionEnabled)
)
)
}
if (partialSettings.idleThresholdMinutes !== undefined) {
promises.push(
setSetting(
SETTING_KEYS.IDLE_THRESHOLD_MINUTES,
String(partialSettings.idleThresholdMinutes)
)
)
}
await Promise.all(promises)
// Return updated settings
return await getAppSettings()
} catch (error) {
console.error('Failed to update app settings:', error)
Sentry.captureException(error, {
tags: { context: 'updateAppSettings' },
extra: { partialSettings },
})
throw error
}
}
/**
* Registers IPC handlers for settings
*/
export function registerSettingsListeners(): void {
// Get all settings
ipcMain.handle('getSettings', async () => {
try {
const appSettings = await getAppSettings()
return { success: true, data: appSettings }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
return { success: false, error: errorMessage }
}
})
// Update settings
ipcMain.handle('updateSettings', async (_event, partialSettings: Partial<AppSettings>) => {
try {
const updatedSettings = await updateAppSettings(partialSettings)
return { success: true, data: updatedSettings }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
return { success: false, error: errorMessage }
}
})
}
+17
View File
@@ -1,3 +1,10 @@
export interface AppSettings {
widgetActivated: boolean
trayTimerActivated: boolean
idleDetectionEnabled: boolean
idleThresholdMinutes: number
}
export interface IElectronAPI {
loadPreferences: () => Promise<void>
showMainWindow: () => void
@@ -15,6 +22,16 @@ export interface IElectronAPI {
onStopTimer: (callback: () => void) => void
updateTrayState: (timeEntry: string, showTimer: boolean) => void
updateAutoUpdater: () => void
updateIdleThreshold: (thresholdMinutes: number) => void
updateIdleDetectionEnabled: (enabled: boolean) => void
timerStateChanged: (running: boolean) => void
onIdleDialogResponse: (
callback: (data: { choice: number; idleStartTime: string; idleEndTime: string }) => void
) => () => void // Returns cleanup function to remove listener
getSettings: () => Promise<{ success: boolean; data?: AppSettings; error?: string }>
updateSettings: (
settings: Partial<AppSettings>
) => Promise<{ success: boolean; data?: AppSettings; error?: string }>
}
declare global {
+13
View File
@@ -31,6 +31,19 @@ if (process.contextIsolated || true) {
updateTrayState: (timeEntry: string, showTimer: boolean) =>
ipcRenderer.send('updateTrayState', timeEntry, showTimer),
updateAutoUpdater: () => ipcRenderer.send('updateAutoUpdater'),
updateIdleThreshold: (thresholdMinutes: number) =>
ipcRenderer.send('updateIdleThreshold', thresholdMinutes),
updateIdleDetectionEnabled: (enabled: boolean) =>
ipcRenderer.send('updateIdleDetectionEnabled', enabled),
timerStateChanged: (running: boolean) => ipcRenderer.send('timerStateChanged', running),
onIdleDialogResponse: (callback) => {
const listener = (_event, value) => callback(value)
ipcRenderer.on('idleDialogResponse', listener)
// Return cleanup function to remove the listener
return () => ipcRenderer.removeListener('idleDialogResponse', listener)
},
getSettings: () => ipcRenderer.invoke('getSettings'),
updateSettings: (settings) => ipcRenderer.invoke('updateSettings', settings),
})
} catch (error) {
console.error(error)
+127 -27
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PrimaryButton } from '@solidtime/ui'
import { PrimaryButton, time, TimeTrackerStartStop } from '@solidtime/ui'
import { Cog6ToothIcon } from '@heroicons/vue/16/solid'
declare global {
@@ -9,28 +9,124 @@ declare global {
}
}
import { onMounted, ref, watchEffect } from 'vue'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
import AutoUpdaterOverlay from './components/AutoUpdaterOverlay.vue'
import { useQueryClient, useQuery } from '@tanstack/vue-query'
import { onMounted, ref, watchEffect, watch, computed } from 'vue'
import { initializeAuth, isLoggedIn, openLoginWindow } from './utils/oauth.ts'
import InstanceSettingsModal from './components/InstanceSettingsModal.vue'
import { hideMiniWindow, showMiniWindow } from './utils/window.ts'
import { isWidgetActivated } from './utils/widget.ts'
import OrganizationSwitcher from './components/OrganizationSwitcher.vue'
import SettingsModal from './components/SettingsModal.vue'
import { useTheme } from './utils/theme.ts'
import SidebarNavigation from './components/SidebarNavigation.vue'
import { isWidgetActivated } from './utils/settings.ts'
import UpdateStatusBar from './components/UpdateStatusBar.vue'
import { useTimer } from './utils/useTimer.ts'
import { useRouter } from 'vue-router'
import { listenForBackendEvent } from './utils/events.ts'
import { getMe } from './utils/me'
import { initializeSettings } from './utils/settings.ts'
import { useLiveTimer } from './utils/liveTimer'
import { dayjs } from './utils/dayjs'
import { useStorage } from '@vueuse/core'
import { emptyTimeEntry } from './utils/timeEntries'
const router = useRouter()
const queryClient = useQueryClient()
onMounted(() => {
// Use the timer composable for shared timer logic
const { stopTimer, startTimer, isActive } = useTimer()
// Live timer for bottom row display
const { liveTimer, startLiveTimer, stopLiveTimer } = useLiveTimer()
const currentTimeEntry = useStorage('currentTimeEntry', { ...emptyTimeEntry })
const currentTime = computed(() => {
if (liveTimer.value && currentTimeEntry.value.start) {
const startTime = dayjs(currentTimeEntry.value.start)
const diff = liveTimer.value.diff(startTime, 'seconds')
return time.formatDuration(diff)
}
return '00:00:00'
})
// Start/stop live timer based on active state
watchEffect(() => {
if (isActive.value) {
startLiveTimer()
} else {
stopLiveTimer()
}
})
// Fetch user data for timezone and week start settings
const { data: meResponse } = useQuery({
queryKey: ['me'],
queryFn: () => getMe(),
})
watch(meResponse, () => {
if (meResponse.value?.data) {
window.getTimezoneSetting = () => meResponse.value.data.timezone
window.getWeekStartSetting = () => meResponse.value.data.week_start
}
})
// Watch timer state and notify main process for idle detection
watch(isActive, (active) => {
if (window.electronAPI?.timerStateChanged) {
window.electronAPI.timerStateChanged(active)
}
})
onMounted(async () => {
window.getTimezoneSetting = () => 'Europe/Vienna'
window.getWeekStartSetting = () => 'monday'
initializeAuth(queryClient)
useTheme()
// Initialize settings from database
await initializeSettings()
// Listen for timer events from mini window
await listenForBackendEvent('startTimer', () => {
startTimer()
})
await listenForBackendEvent('stopTimer', () => {
stopTimer()
})
// Listen for idle dialog response from main process
if (window.electronAPI?.onIdleDialogResponse) {
window.electronAPI.onIdleDialogResponse((data) => {
handleIdleDialogResponse(data.choice, data.idleStartTime)
})
}
})
async function handleIdleDialogResponse(choice: number, idleStartTime: string) {
switch (choice) {
case 0: // Keep Idle Time
// Do nothing - keep timer running as-is
break
case 1: // Discard Idle Time
// Stop the timer and set the end time to when idle started
await stopTimer(idleStartTime)
break
case 2: // Discard & Start New Timer
// Stop the timer and set the end time to when idle started
await stopTimer(idleStartTime)
// Start a new timer after the stop completes
startTimer()
break
}
}
watchEffect(async () => {
if (isLoggedIn.value && isWidgetActivated.value) {
showMiniWindow()
@@ -39,7 +135,6 @@ watchEffect(async () => {
}
})
const showSettingsModal = ref(false)
const showInstanceSettingsModal = ref(false)
import { useMagicKeys, whenever } from '@vueuse/core'
@@ -47,19 +142,14 @@ const keys = useMagicKeys()
const cmdComma = keys['Cmd+,']
whenever(cmdComma, () => {
if (isLoggedIn.value) {
showSettingsModal.value = true
router.push('/settings')
} else {
showInstanceSettingsModal.value = true
}
})
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
import AutoUpdaterOverlay from './components/AutoUpdaterOverlay.vue'
import { useQueryClient } from '@tanstack/vue-query'
</script>
<template>
<VueQueryDevtools></VueQueryDevtools>
<AutoUpdaterOverlay></AutoUpdaterOverlay>
<div class="flex h-screen">
<div class="flex-1 flex flex-col">
@@ -69,23 +159,33 @@ import { useQueryClient } from '@tanstack/vue-query'
<div v-if="isLoggedIn" class="flex items-center space-x-2">
<UpdateStatusBar></UpdateStatusBar>
<OrganizationSwitcher></OrganizationSwitcher>
<button @click="showSettingsModal = true">
<Cog6ToothIcon
class="w-5 cursor-pointer text-muted opacity-50 hover:opacity-100"></Cog6ToothIcon>
</button>
<SettingsModal
:show="showSettingsModal"
@close="showSettingsModal = false"></SettingsModal>
</div>
</div>
<div class="flex-1 flex overflow-hidden" v-if="isLoggedIn">
<SidebarNavigation v-if="isLoggedIn" />
<router-view v-slot="{ Component }">
<!-- full-calendar has an issue with keep-alive https://github.com/fullcalendar/fullcalendar/issues/7886 -->
<keep-alive exclude="calendar">
<component :is="Component" />
</keep-alive>
</router-view>
<div class="flex-1 flex flex-col overflow-hidden" v-if="isLoggedIn">
<div class="flex-1 flex overflow-hidden">
<SidebarNavigation />
<router-view v-slot="{ Component }">
<!-- full-calendar has an issue with keep-alive https://github.com/fullcalendar/fullcalendar/issues/7886 -->
<keep-alive exclude="CalendarPage">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<!-- Footer Timer -->
<div
class="h-10 w-full bg-background border-t border-border-primary flex items-center justify-between px-4">
<div class="flex items-center space-x-3">
<div v-if="isActive" class="flex items-center space-x-3">
<div class="text-text-tertiary font-medium text-xs">Current Timer</div>
<div class="text-text-primary font-medium text-sm">
{{ currentTime }}
</div>
</div>
<div v-else class="text-text-tertiary font-medium text-xs">
No timer running
</div>
</div>
</div>
</div>
<div v-else class="flex-1">
<div class="flex flex-col space-y-6 py-12 items-center justify-center">
@@ -44,17 +44,25 @@ import { useStorage, useElementVisibility } from '@vueuse/core'
import { currentMembershipId, useMyMemberships } from '../utils/myMemberships.ts'
import { getAllClients, useClientCreateMutation } from '../utils/clients.ts'
import { dayjs } from '../utils/dayjs.ts'
import { listenForBackendEvent } from '../utils/events.ts'
import { fromError } from 'zod-validation-error'
import { apiClient } from '../utils/api'
import { updateTrayState, isTrayTimerActivated } from '../utils/tray'
import { getMe } from '../utils/me'
import { updateTrayState } from '../utils/tray'
import { isTrayTimerActivated } from '../utils/settings'
import { time } from '@solidtime/ui'
import { useTimer } from '../utils/useTimer.ts'
const { getDayJsInstance } = time
const { currentOrganizationId, currentMembership } = useMyMemberships()
const currentOrganizationLoaded = computed(() => !!currentOrganizationId.value)
const { liveTimer, startLiveTimer, stopLiveTimer } = useLiveTimer()
// Use the timer composable for shared timer logic
const { currentTimeEntry, lastTimeEntry, isActive, stopTimer, startTimer, timeEntryCreate } =
useTimer()
const selectedTimeEntries = ref([] as TimeEntry[])
watch(currentOrganizationId, () => {
selectedTimeEntries.value = []
})
@@ -89,9 +97,7 @@ const { data: currentTimeEntryResponse, isError: currentTimeEntryResponseIsError
queryFn: () => getCurrentTimeEntry(),
})
const currentTimeEntry = useStorage<TimeEntry>('currentTimeEntry', { ...emptyTimeEntry })
const lastTimeEntry = useStorage<TimeEntry>('lastTimeEntry', { ...emptyTimeEntry })
// Update lastTimeEntry when timeEntries change
watch(timeEntries, () => {
if (timeEntries.value?.[0]) {
lastTimeEntry.value = { ...timeEntries.value?.[0] }
@@ -147,8 +153,6 @@ const timeEntriesUpdate = useTimeEntriesUpdateMutation()
const timeEntriesDelete = useTimeEntriesDeleteMutation()
const timeEntryUpdate = useTimeEntryUpdateMutation()
const timeEntryDelete = useTimeEntryDeleteMutation()
const timeEntryCreate = useTimeEntryCreateMutation()
const timeEntryStop = useTimeEntryStopMutation()
const tagCreate = useTagCreateMutation()
function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
@@ -176,17 +180,7 @@ async function createTag(newTagName: string): Promise<Tag | undefined> {
return undefined
}
const isActive = computed(() => {
if (currentTimeEntry.value) {
return (
currentTimeEntry.value.start !== '' &&
currentTimeEntry.value.start !== null &&
currentTimeEntry.value.end === null
)
}
return false
})
// Watch for current time entry changes and update tray state
watch(currentTimeEntry, () => {
updateTrayState({ ...currentTimeEntry.value })
})
@@ -195,6 +189,7 @@ watch(isTrayTimerActivated, () => {
updateTrayState({ ...currentTimeEntry.value })
})
// Watch for active state changes and manage live timer
watchEffect(() => {
if (isActive.value) {
startLiveTimer()
@@ -213,24 +208,6 @@ function updateCurrentTimeEntry() {
}
}
function startTimer() {
if (currentTimeEntry.value.start === '') {
currentTimeEntry.value.start = dayjs().utc().format()
}
createTimeEntry(currentTimeEntry.value)
startLiveTimer()
}
async function stopTimer() {
const stoppedTimeEntry = { ...currentTimeEntry.value }
currentMembershipId.value = memberships.value.find(
(membership) => membership.organization.id === stoppedTimeEntry.organization_id
)?.id
currentTimeEntry.value = { ...emptyTimeEntry }
stopLiveTimer()
timeEntryStop.mutate({ ...stoppedTimeEntry, end: dayjs().utc().format() })
}
function discardTimer() {
// If there's an active timer with an ID, delete it from the backend
if (currentTimeEntry.value?.id && currentTimeEntry.value.id !== '') {
@@ -240,26 +217,6 @@ function discardTimer() {
stopLiveTimer()
}
onMounted(async () => {
await listenForBackendEvent('startTimer', () => {
if (lastTimeEntry.value) {
currentTimeEntry.value.project_id = lastTimeEntry.value.project_id
currentTimeEntry.value.task_id = lastTimeEntry.value.task_id
currentTimeEntry.value.description = lastTimeEntry.value.description
currentTimeEntry.value.tags = lastTimeEntry.value.tags
currentTimeEntry.value.billable = lastTimeEntry.value.billable
currentTimeEntry.value.start = dayjs().utc().format()
}
createTimeEntry(currentTimeEntry.value)
startLiveTimer()
})
await listenForBackendEvent('stopTimer', () => {
nextTick(() => {
stopTimer()
})
})
})
const projectCreateMutation = useProjectCreateMutation()
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -302,20 +259,6 @@ function switchOrganization() {
}
}
const { data: meResponse } = useQuery({
queryKey: ['me'],
queryFn: () => getMe(),
})
watch(meResponse, () => {
if (meResponse.value?.data) {
window.getTimezoneSetting = () => meResponse.value.data.timezone
window.getWeekStartSetting = () => meResponse.value.data.week_start
}
})
const selectedTimeEntries = ref([] as TimeEntry[])
function deleteSelected() {
timeEntriesDelete.mutate(selectedTimeEntries.value)
selectedTimeEntries.value = []
@@ -358,7 +301,7 @@ watch(isLoadMoreVisible, async (isVisible) => {
v-if="timeEntries && projects && tasks && tags && clients"
class="flex flex-col h-full">
<div class="flex bg-background">
<div class="pl-4 pb-4 pt-2 border-b border-border-primary z-10 w-full top-0 left-0">
<div class="pl-4 pb-4 pt-4 border-b border-border-primary z-10 w-full top-0 left-0">
<CardTitle title="Time Tracker" :icon="ClockIcon as Component"></CardTitle>
<div class="relative">
<TimeTrackerRunningInDifferentOrganizationOverlay
@@ -1,127 +0,0 @@
<script setup lang="ts">
import { Modal, PrimaryButton, SecondaryButton, Checkbox, LoadingSpinner } from '@solidtime/ui'
import { logout } from '../utils/oauth.ts'
import { isWidgetActivated } from '../utils/widget.ts'
import { isTrayTimerActivated } from '../utils/tray.ts'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getMe } from '../utils/me'
import { computed, onMounted, ref } from 'vue'
const emit = defineEmits(['close'])
const { data } = useQuery({
queryKey: ['me'],
queryFn: () => getMe(),
})
const showUpdateNotAvailable = ref(false)
const checkingForUpdate = ref(false)
const showErrorOnUpdateRequest = ref(false)
const myData = computed(() => data.value?.data)
defineProps({
show: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
closeable: {
type: Boolean,
default: true,
},
})
const close = () => {
emit('close')
}
const queryClient = useQueryClient()
function onLogoutClick() {
emit('close')
logout(queryClient)
}
function triggerUpdate() {
checkingForUpdate.value = true
window.electronAPI.updateAutoUpdater()
}
onMounted(() => {
window.electronAPI.onUpdateNotAvailable(() => {
showUpdateNotAvailable.value = true
checkingForUpdate.value = false
setTimeout(() => {
showUpdateNotAvailable.value = false
}, 5000)
})
window.electronAPI.onAutoUpdaterError(async () => {
showUpdateNotAvailable.value = true
showErrorOnUpdateRequest.value = true
checkingForUpdate.value = false
setTimeout(() => {
showUpdateNotAvailable.value = false
showErrorOnUpdateRequest.value = false
}, 5000)
})
})
</script>
<template>
<Modal :show="show" :maxWidth="maxWidth" :closeable="closeable" @close="close">
<div class="px-6 py-4">
<div class="mb-2 font-medium text-muted" role="heading">User Information</div>
<div>
<div v-if="myData" class="flex justify-between items-center">
<div class="flex items-center mt-3 space-x-3">
<img
:src="myData.profile_photo_url"
class="rounded-full w-14 h-14 object-cover"
alt="Profile image" />
<div>
<div class="text-sm text-muted py-0.5">
<strong>Name:</strong> {{ myData.name }}
</div>
<div class="text-sm text-muted py-0.5">
<strong>Email:</strong> {{ myData.email }}
</div>
</div>
</div>
<PrimaryButton @click="onLogoutClick">Logout</PrimaryButton>
</div>
</div>
<div class="mb-2 mt-6 font-medium text-muted" role="heading">Settings</div>
<div class="my-4 text-sm text-muted flex flex-col justify-center space-y-3">
<label class="flex items-center">
<Checkbox v-model:checked="isWidgetActivated" name="remember" />
<span class="ms-2 text-sm">Show Timetracker Widget</span>
</label>
<label class="flex items-center">
<Checkbox v-model:checked="isTrayTimerActivated" name="tray_timer" />
<span class="ms-2 text-sm">Show Tray / Menu Bar Timer</span>
</label>
</div>
<div class="mb-2 mt-6 font-medium text-muted" role="heading">Updates</div>
<div class="flex items-center space-x-4">
<SecondaryButton :disabled="checkingForUpdate" @click="triggerUpdate">
<div class="flex items-center">
<LoadingSpinner v-if="checkingForUpdate"></LoadingSpinner>
<span>Check for updates</span>
</div>
</SecondaryButton>
<div v-if="showUpdateNotAvailable" class="flex text-sm text-text-primary">
No update available.
<span v-if="showErrorOnUpdateRequest"
>There was an error while fetching the update.</span
>
</div>
</div>
</div>
<div
class="flex flex-row justify-end px-6 py-4 border-t border-card-background-separator bg-default-background rounded-b-2xl text-end">
<SecondaryButton @click="close">Close</SecondaryButton>
</div>
</Modal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ClockIcon, CalendarIcon, ChartPieIcon } from '@heroicons/vue/24/outline'
import { ClockIcon, CalendarIcon, Cog6ToothIcon } from '@heroicons/vue/24/outline'
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'
import { Button, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@solidtime/ui'
@@ -19,9 +19,9 @@ const navItems = [
icon: CalendarIcon,
},
{
name: 'Statistics',
path: '/statistics',
icon: ChartPieIcon,
name: 'Settings',
path: '/settings',
icon: Cog6ToothIcon,
},
]
+47
View File
@@ -19,6 +19,7 @@ import type {
TimeEntry,
TimeEntryResponse,
} from '@solidtime/api'
import type { ActivityPeriod } from '@solidtime/ui'
const { currentOrganizationId, currentMembership } = useMyMemberships()
const currentOrganizationLoaded = computed(() => !!currentOrganizationId.value)
@@ -205,6 +206,51 @@ function onRefresh() {
queryKey: ['timeEntry', 'calendar'],
})
}
// Fetch activity periods from the database
const { data: activityPeriodsData } = useQuery<ActivityPeriod[]>({
queryKey: computed(() => [
'activityPeriods',
{
start: expandedDateRange.value.start,
end: expandedDateRange.value.end,
},
]),
enabled: enableCalendarQuery,
placeholderData: (previousData) => previousData,
queryFn: async () => {
try {
if (!expandedDateRange.value.start || !expandedDateRange.value.end) {
return []
}
// Call the IPC handler to get activity periods from the database
const result = await window.electron.ipcRenderer.invoke(
'getActivityPeriods',
expandedDateRange.value.start,
expandedDateRange.value.end
)
// Handle the new response format with success/data/error
if (result && result.success && result.data) {
return result.data
}
if (result && result.error) {
console.error('Error fetching activity periods:', result.error)
}
return []
} catch (error) {
console.error('Failed to fetch activity periods:', error)
return []
}
},
})
const activityPeriods = computed<ActivityPeriod[]>(() => {
return activityPeriodsData.value || []
})
</script>
<template>
@@ -219,6 +265,7 @@ function onRefresh() {
:tasks="tasks"
:clients="clients"
:tags="tags"
:activity-periods="activityPeriods"
:loading="timeEntriesLoading"
:enable-estimated-time="false"
:currency="currentMembership?.organization?.currency || 'USD'"
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { PrimaryButton, SecondaryButton, Checkbox, LoadingSpinner } from '@solidtime/ui'
import { logout } from '../utils/oauth.ts'
import {
isWidgetActivated,
isTrayTimerActivated,
idleDetectionEnabled,
idleThresholdMinutes,
} from '../utils/settings.ts'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getMe } from '../utils/me'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: ['me'],
queryFn: () => getMe(),
})
const showUpdateNotAvailable = ref(false)
const checkingForUpdate = ref(false)
const showErrorOnUpdateRequest = ref(false)
const myData = computed(() => data.value?.data)
function onLogoutClick() {
logout(queryClient)
router.push('/time')
}
function triggerUpdate() {
checkingForUpdate.value = true
window.electronAPI.updateAutoUpdater()
}
onMounted(() => {
window.electronAPI.onUpdateNotAvailable(() => {
showUpdateNotAvailable.value = true
checkingForUpdate.value = false
setTimeout(() => {
showUpdateNotAvailable.value = false
}, 5000)
})
window.electronAPI.onAutoUpdaterError(async () => {
showUpdateNotAvailable.value = true
showErrorOnUpdateRequest.value = true
checkingForUpdate.value = false
setTimeout(() => {
showUpdateNotAvailable.value = false
showErrorOnUpdateRequest.value = false
}, 5000)
})
})
// Watch for idle detection settings changes and notify main process
watch(idleDetectionEnabled, (enabled) => {
window.electronAPI.updateIdleDetectionEnabled(enabled)
})
watch(idleThresholdMinutes, (minutes) => {
window.electronAPI.updateIdleThreshold(minutes)
})
</script>
<template>
<div class="flex-1 overflow-auto">
<div class="max-w-4xl mx-auto p-8">
<h1 class="text-2xl font-semibold mb-8">Settings</h1>
<div
class="bg-card-background rounded-lg border border-card-background-separator p-6 mb-6">
<div class="mb-4 text-lg font-medium">User Information</div>
<div v-if="myData" class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<img
:src="myData.profile_photo_url"
class="rounded-full w-14 h-14 object-cover"
alt="Profile image" />
<div>
<div class="text-sm text-muted py-0.5">
<strong>Name:</strong> {{ myData.name }}
</div>
<div class="text-sm text-muted py-0.5">
<strong>Email:</strong> {{ myData.email }}
</div>
</div>
</div>
<PrimaryButton @click="onLogoutClick">Logout</PrimaryButton>
</div>
</div>
<div
class="bg-card-background rounded-lg border border-card-background-separator p-6 mb-6">
<div class="mb-4 text-lg font-medium">Preferences</div>
<div class="space-y-4">
<label class="flex items-center">
<Checkbox v-model:checked="isWidgetActivated" name="remember" />
<span class="ms-2 text-sm">Show Timetracker Widget</span>
</label>
<label class="flex items-center">
<Checkbox v-model:checked="isTrayTimerActivated" name="tray_timer" />
<span class="ms-2 text-sm">Show Tray / Menu Bar Timer</span>
</label>
<label class="flex items-center">
<Checkbox v-model:checked="idleDetectionEnabled" name="idleDetection" />
<span class="ms-2 text-sm">Enable Idle Detection</span>
</label>
<div v-if="idleDetectionEnabled" class="ml-6 flex items-center space-x-2">
<label for="idleThreshold" class="text-sm">Idle threshold (minutes):</label>
<input
id="idleThreshold"
v-model.number="idleThresholdMinutes"
type="number"
min="1"
max="60"
class="w-20 px-2 py-1 text-sm bg-card-background border border-card-background-separator rounded focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
</div>
<div class="bg-card-background rounded-lg border border-card-background-separator p-6">
<div class="mb-4 text-lg font-medium">Updates</div>
<div class="flex items-center space-x-4">
<SecondaryButton :disabled="checkingForUpdate" @click="triggerUpdate">
<div class="flex items-center">
<LoadingSpinner v-if="checkingForUpdate"></LoadingSpinner>
<span>Check for updates</span>
</div>
</SecondaryButton>
<div v-if="showUpdateNotAvailable" class="flex text-sm text-text-primary">
No update available.
<span v-if="showErrorOnUpdateRequest"
>There was an error while fetching the update.</span
>
</div>
</div>
</div>
</div>
</div>
</template>
+6
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import TimePage from '../pages/TimePage.vue'
import CalendarPage from '../pages/CalendarPage.vue'
import SettingsPage from '../pages/SettingsPage.vue'
import { useQueryClient } from '@tanstack/vue-query'
const routes = [
@@ -18,6 +19,11 @@ const routes = [
name: 'calendar',
component: CalendarPage,
},
{
path: '/settings',
name: 'settings',
component: SettingsPage,
},
]
const router = createRouter({
+72
View File
@@ -0,0 +1,72 @@
import { ref, watch } from 'vue'
export interface AppSettings {
widgetActivated: boolean
trayTimerActivated: boolean
idleDetectionEnabled: boolean
idleThresholdMinutes: number
}
// Reactive settings that sync with the database
export const isWidgetActivated = ref(true)
export const isTrayTimerActivated = ref(true)
export const idleDetectionEnabled = ref(true)
export const idleThresholdMinutes = ref(5)
let isInitialized = false
/**
* Initialize settings from the database
*/
export async function initializeSettings() {
if (isInitialized) return
try {
const result = await window.electronAPI.getSettings()
if (result.success && result.data) {
isWidgetActivated.value = result.data.widgetActivated
isTrayTimerActivated.value = result.data.trayTimerActivated
idleDetectionEnabled.value = result.data.idleDetectionEnabled
idleThresholdMinutes.value = result.data.idleThresholdMinutes
}
isInitialized = true
// Watch for changes and sync to database
watch(isWidgetActivated, (value) => {
updateSetting({ widgetActivated: value })
})
watch(isTrayTimerActivated, (value) => {
updateSetting({ trayTimerActivated: value })
})
watch(idleDetectionEnabled, (value) => {
updateSetting({ idleDetectionEnabled: value })
// Also notify main process for idle detection
window.electronAPI.updateIdleDetectionEnabled(value)
})
watch(idleThresholdMinutes, (value) => {
updateSetting({ idleThresholdMinutes: value })
// Also notify main process for idle detection
window.electronAPI.updateIdleThreshold(value)
})
} catch (error) {
console.error('Failed to initialize settings:', error)
}
}
/**
* Update settings in the database
*/
async function updateSetting(partialSettings: Partial<AppSettings>) {
try {
const result = await window.electronAPI.updateSettings(partialSettings)
if (!result.success) {
console.error('Failed to update settings:', result.error)
}
} catch (error) {
console.error('Failed to update settings:', error)
}
}
+33 -6
View File
@@ -46,6 +46,39 @@ export function getAllTimeEntries(
})
}
export function getTimeEntriesPage(
currentOrganizationId: string | null,
membershipId: string | null,
endDate?: string
) {
if (currentOrganizationId === null) {
throw new Error('No current organization id - all time entries')
}
if (membershipId === null) {
throw new Error('No current member id - all time entries')
}
const queries: {
only_full_dates: string
member_id: string
end?: string
} = {
only_full_dates: 'true',
member_id: membershipId,
}
if (endDate) {
queries.end = endDate
}
return apiClient.value.getTimeEntries({
params: {
organization: currentOrganizationId,
},
queries,
})
}
export function getCurrentTimeEntry() {
return apiClient.value.getMyActiveTimeEntry({})
}
@@ -82,12 +115,6 @@ export function useTimeEntryStopMutation() {
await queryClient.cancelQueries({ queryKey: ['timeEntries', currentOrganizationId] })
await queryClient.cancelQueries({ queryKey: ['currentTimeEntry'] })
queryClient.setQueryData(
['timeEntries', currentOrganizationId],
(old: TimeEntryResponse) => {
return { data: [timeEntry, ...old.data] }
}
)
queryClient.setQueryData(['currentTimeEntry'], () => emptyTimeEntry)
return { timeEntry }
+1 -3
View File
@@ -1,7 +1,5 @@
import { type TimeEntry } from '@solidtime/api'
import { useStorage } from '@vueuse/core'
export const isTrayTimerActivated = useStorage('is_tray_timer_activated', true)
import { isTrayTimerActivated } from './settings'
export async function updateTrayState(timeEntry: TimeEntry) {
window.electronAPI.updateTrayState(JSON.stringify(timeEntry), isTrayTimerActivated.value)
+111
View File
@@ -0,0 +1,111 @@
import { computed } from 'vue'
import { useStorage } from '@vueuse/core'
import type { TimeEntry, CreateTimeEntryBody } from '@solidtime/api'
import {
emptyTimeEntry,
useTimeEntryStopMutation,
useTimeEntryCreateMutation,
} from './timeEntries.ts'
import { currentMembershipId, useMyMemberships } from './myMemberships.ts'
import { time } from '@solidtime/ui'
const { getDayJsInstance } = time
/**
* Composable for managing timer state and operations
* Provides shared logic for starting/stopping timers across components
* NOTE: This should only be used in the renderer process (browser context)
*/
export function useTimer() {
// Access current time entry from storage (only works in browser context)
const currentTimeEntry = useStorage<TimeEntry>(
'currentTimeEntry',
{ ...emptyTimeEntry },
typeof window !== 'undefined' ? localStorage : undefined
)
const lastTimeEntry = useStorage<TimeEntry>(
'lastTimeEntry',
{ ...emptyTimeEntry },
typeof window !== 'undefined' ? localStorage : undefined
)
// Get mutations for timer operations
const timeEntryStop = useTimeEntryStopMutation()
const timeEntryCreate = useTimeEntryCreateMutation()
const { memberships } = useMyMemberships()
/**
* Check if there's an active timer running
*/
const isActive = computed(() => {
if (currentTimeEntry.value) {
return (
currentTimeEntry.value.start !== '' &&
currentTimeEntry.value.start !== null &&
currentTimeEntry.value.end === null
)
}
return false
})
/**
* Stop the current timer
* @param endTime - Optional end time (ISO string). If not provided, uses current time
*/
async function stopTimer(endTime?: string) {
const stoppedTimeEntry = { ...currentTimeEntry.value }
currentMembershipId.value = memberships.value.find(
(membership) => membership.organization.id === stoppedTimeEntry.organization_id
)?.id
currentTimeEntry.value = { ...emptyTimeEntry }
await timeEntryStop.mutateAsync({
...stoppedTimeEntry,
end: endTime || getDayJsInstance()().utc().format(),
})
}
/**
* Start a new timer
* Copies properties from the last time entry if available
*/
function startTimer() {
const startTime = getDayJsInstance()().utc().format()
if (lastTimeEntry.value && lastTimeEntry.value.start) {
// Copy properties from last entry
currentTimeEntry.value = {
...emptyTimeEntry,
project_id: lastTimeEntry.value.project_id,
task_id: lastTimeEntry.value.task_id,
description: lastTimeEntry.value.description,
tags: lastTimeEntry.value.tags,
billable: lastTimeEntry.value.billable,
start: startTime,
}
} else {
// First timer - start fresh
currentTimeEntry.value = {
...emptyTimeEntry,
start: startTime,
}
}
const timeEntryToCreate: CreateTimeEntryBody = {
...currentTimeEntry.value,
member_id: currentMembershipId.value,
}
timeEntryCreate.mutate(timeEntryToCreate)
}
return {
currentTimeEntry,
lastTimeEntry,
isActive,
stopTimer,
startTimer,
timeEntryStop,
timeEntryCreate,
}
}
-3
View File
@@ -1,3 +0,0 @@
import { useStorage } from '@vueuse/core'
export const isWidgetActivated = useStorage('is_widget_activated', true)