mirror of
https://github.com/solidtime-io/solidtime-desktop.git
synced 2026-05-07 20:32:27 +00:00
add initial idle detection implementation
This commit is contained in:
@@ -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
|
||||
);
|
||||
@@ -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'));
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1403
-661
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+17
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
export const isWidgetActivated = useStorage('is_widget_activated', true)
|
||||
Reference in New Issue
Block a user