3 Commits
v1.0.0 ... main

Author SHA1 Message Date
Your Name
130d0e2ca6 feat: linux appimage build with docker, egl fallback, and webkitgtk fixes 2026-02-27 13:26:04 +02:00
Your Name
507fa33be8 fix: auto-detect date format (DD/MM vs MM/DD) in CSV imports
Scans all date values in imported CSVs to determine whether the file
uses DD/MM/YYYY or MM/DD/YYYY format. When the format is ambiguous
(all day and month values are <= 12), shows an inline dropdown for the
user to choose. Bump version to 1.0.2.
2026-02-21 16:56:27 +02:00
Your Name
f4f964140b fix: close button and CSV import parsing for Clockify/Harvest
Close button did nothing when "close to tray" was disabled - the
onCloseRequested handler lacked an explicit destroy call for the
non-tray path.

Clockify CSV import threw RangeError because locale-dependent date
formats (MM/DD/YYYY, DD.MM.YYYY, 12h time) were passed straight
to the Date constructor. Added flexible date/time parsers that
handle all Clockify export variants without relying on Date parsing.

Added dedicated Clockify mapper that prefers Duration (decimal)
column and a new Harvest CSV importer (date + decimal hours, no
start/end times).

Bump version to 1.0.1.
2026-02-21 14:56:53 +02:00
22 changed files with 1495 additions and 117 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
src-tauri/target
dist
dist-appimage
.claude
.git
docs
trash

40
.gitignore vendored
View File

@@ -1,40 +0,0 @@
node_modules
dist
docs
trash
# Rust/Tauri build artifacts
src-tauri/target
src-tauri/gen
# AI/LLM tools
.claude
.claude/*
CLAUDE.md
.cursorrules
.cursor
.cursor/
.copilot
.copilot/
.github/copilot
.aider*
.aiderignore
.continue
.continue/
.ai
.ai/
.llm
.llm/
.windsurf
.windsurf/
.codeium
.codeium/
.tabnine
.tabnine/
.sourcery
.sourcery/
cursor.rules
.bolt
.bolt/
.v0
.v0/

81
Dockerfile.appimage Normal file
View File

@@ -0,0 +1,81 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Tauri v2 build dependencies for Ubuntu 24.04 (glibc 2.39, WebKitGTK 2.44+)
# Targets any distro from mid-2024 onwards while supporting modern Wayland compositors
RUN apt-get update && apt-get install -y \
build-essential \
curl \
wget \
file \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libayatana-appindicator3-dev \
libglib2.0-dev \
libgdk-pixbuf2.0-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 22 LTS
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Rust stable
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /app
# Cache Rust dependencies first
COPY src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/
COPY src-tauri/build.rs src-tauri/
# Create stub lib/main so cargo can resolve the crate
RUN mkdir -p src-tauri/src && \
echo 'fn main() {}' > src-tauri/src/main.rs && \
echo '' > src-tauri/src/lib.rs && \
cd src-tauri && cargo fetch && \
rm -rf src
# Cache npm dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy the full source
COPY . .
# Build the AppImage
RUN npx tauri build --bundles appimage
# Strip GPU/GL libraries that linuxdeploy bundles from the build system.
# These MUST come from the host's GPU driver at runtime - bundling them causes
# black windows, EGL failures, and driver mismatches on any system with a
# different Mesa version or proprietary NVIDIA drivers.
# See: https://github.com/AppImage/pkg2appimage/blob/master/excludelist
RUN cd src-tauri/target/release/bundle/appimage && \
chmod +x *.AppImage && \
./*.AppImage --appimage-extract && \
find squashfs-root \( \
-name "libEGL.so*" -o \
-name "libGL.so*" -o \
-name "libGLX.so*" -o \
-name "libGLdispatch.so*" -o \
-name "libOpenGL.so*" -o \
-name "libgbm.so*" -o \
-name "libdrm.so*" -o \
-name "libglapi.so*" -o \
-name "libGLESv2.so*" \
\) -delete && \
rm -f *.AppImage && \
wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O /tmp/appimagetool && \
chmod +x /tmp/appimagetool && \
ARCH=x86_64 /tmp/appimagetool --appimage-extract-and-run --no-appstream squashfs-root/ && \
rm -rf squashfs-root /tmp/appimagetool
# The AppImage will be in src-tauri/target/release/bundle/appimage/

View File

@@ -181,7 +181,52 @@ Accessibility is not a feature. It is a baseline.
--- ---
## 🚀 Getting started ## 📦 Downloads
Grab the latest release from the [releases page](https://git.lashman.live/lashman/zeroclock/releases).
| Platform | File | Notes |
|----------|------|-------|
| Windows x64 | `zeroclock-v*.exe` | Portable executable, no installer needed |
| Linux x86_64 | `ZeroClock-x86_64.AppImage` | Portable AppImage, no installation needed |
### Linux AppImage
Download, make executable, and run:
```bash
chmod +x ZeroClock-x86_64.AppImage
./ZeroClock-x86_64.AppImage
```
- Built on Ubuntu 24.04 (glibc 2.39) - compatible with most distros from mid-2024 onwards
- Portable - data is stored next to the AppImage file
- Automatically installs a `.desktop` file and icon on first run
- Wayland and X11 supported
**Troubleshooting:**
If you see a black or blank window, try:
```bash
WEBKIT_DISABLE_DMABUF_RENDERER=0 ./ZeroClock-x86_64.AppImage
```
If that does not help:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./ZeroClock-x86_64.AppImage
```
NixOS users should run via nixGL:
```bash
nixGL appimage-run ./ZeroClock-x86_64.AppImage
```
---
## 🚀 Building from source
### Prerequisites ### Prerequisites
@@ -193,7 +238,7 @@ Accessibility is not a feature. It is a baseline.
```bash ```bash
# Clone the repository # Clone the repository
git clone https://github.com/your-username/zeroclock.git git clone https://git.lashman.live/lashman/zeroclock.git
cd zeroclock cd zeroclock
# Install frontend dependencies # Install frontend dependencies
@@ -206,6 +251,16 @@ npx tauri dev
npx tauri build npx tauri build
``` ```
### Building the Linux AppImage
To build a portable AppImage with broad compatibility, use the Docker build script:
```bash
./build-appimage.sh
```
This builds inside an Ubuntu 24.04 container and outputs to `dist-appimage/`. Requires Docker.
The database is created automatically on first launch in the same directory as the executable. The database is created automatically on first launch in the same directory as the executable.
--- ---

21
build-appimage.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGE_NAME="zeroclock-appimage-builder"
OUTPUT_DIR="$SCRIPT_DIR/dist-appimage"
echo "Building Docker image (Ubuntu 24.04 / glibc 2.39)..."
docker build -f Dockerfile.appimage -t "$IMAGE_NAME" "$SCRIPT_DIR"
echo "Extracting AppImage..."
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
CONTAINER_ID=$(docker create "$IMAGE_NAME")
docker cp "$CONTAINER_ID:/app/src-tauri/target/release/bundle/appimage/." "$OUTPUT_DIR/"
docker rm "$CONTAINER_ID" > /dev/null
echo ""
echo "Done! AppImage(s) in: $OUTPUT_DIR/"
ls -lh "$OUTPUT_DIR/"*.AppImage 2>/dev/null || echo "(no .AppImage files found - check build output above)"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "zeroclock", "name": "zeroclock",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "zeroclock", "name": "zeroclock",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.2.0", "@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "zeroclock", "name": "zeroclock",
"version": "1.0.0", "version": "1.0.2",
"description": "Time tracking desktop application", "description": "Time tracking desktop application",
"type": "module", "type": "module",
"scripts": { "scripts": {

3
src-tauri/Cargo.lock generated
View File

@@ -5743,10 +5743,11 @@ dependencies = [
[[package]] [[package]]
name = "zeroclock" name = "zeroclock"
version = "1.0.0" version = "1.0.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
"gtk",
"log", "log",
"png", "png",
"rusqlite", "rusqlite",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "zeroclock" name = "zeroclock"
version = "1.0.0" version = "1.0.2"
description = "A local time tracking app with invoicing" description = "A local time tracking app with invoicing"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -26,11 +26,13 @@ chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
png = "0.17"
[dependencies.windows] [target.'cfg(target_os = "linux")'.dependencies]
version = "0.58" gtk = "0.18"
features = [
[target.'cfg(windows)'.dependencies]
png = "0.17"
windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_System_SystemInformation", "Win32_System_SystemInformation",
@@ -39,7 +41,7 @@ features = [
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_Foundation", "Win32_Foundation",
] ] }
[profile.release] [profile.release]
panic = "abort" panic = "abort"

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use crate::os_detection; use crate::os_detection;
use rusqlite::params; use rusqlite::params;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{Manager, State}; use tauri::{AppHandle, Manager, State};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Client { pub struct Client {
@@ -1102,6 +1102,17 @@ pub struct TrackedApp {
pub display_name: Option<String>, pub display_name: Option<String>,
} }
// Platform detection
#[tauri::command]
pub fn get_platform() -> String {
#[cfg(target_os = "linux")]
{ "linux".to_string() }
#[cfg(target_os = "windows")]
{ "windows".to_string() }
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{ "unknown".to_string() }
}
// OS Detection commands // OS Detection commands
#[tauri::command] #[tauri::command]
pub fn get_idle_seconds() -> Result<u64, String> { pub fn get_idle_seconds() -> Result<u64, String> {
@@ -3754,3 +3765,137 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
] ]
} }
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
/// Play a synthesized notification sound on Linux via system audio tools.
/// Generates a WAV in memory and pipes it through paplay/pw-play/aplay.
/// `tones` is a JSON array of {freq, duration_ms, delay_ms, detune} objects.
/// `volume` is 0.0-1.0.
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn play_sound(tones: Vec<SoundTone>, volume: f64) {
std::thread::spawn(move || {
play_sound_inner(&tones, volume);
});
}
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub fn play_sound(_tones: Vec<SoundTone>, _volume: f64) {
// No-op on non-Linux; frontend uses Web Audio API directly
}
#[derive(Debug, Deserialize)]
pub struct SoundTone {
pub freq: f64,
pub duration_ms: u32,
pub delay_ms: u32,
#[serde(default)]
pub freq_end: Option<f64>,
#[serde(default)]
pub detune: Option<f64>,
}
#[cfg(target_os = "linux")]
fn play_sound_inner(tones: &[SoundTone], volume: f64) {
const SAMPLE_RATE: u32 = 44100;
let vol = volume.clamp(0.0, 1.0) as f32;
// Calculate total duration
let total_ms: u32 = tones.iter().map(|t| t.delay_ms + t.duration_ms).sum();
let total_samples = ((total_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize + SAMPLE_RATE as usize / 10; // +100ms padding
let mut samples = vec![0i16; total_samples];
let mut offset_ms: u32 = 0;
for tone in tones {
offset_ms += tone.delay_ms;
let start_sample = ((offset_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
let num_samples = ((tone.duration_ms as f64 / 1000.0) * SAMPLE_RATE as f64) as usize;
let attack_samples = (0.010 * SAMPLE_RATE as f64) as usize; // 10ms attack
let release_samples = (0.050 * SAMPLE_RATE as f64) as usize; // 50ms release
let freq_start = tone.freq;
let freq_end = tone.freq_end.unwrap_or(tone.freq);
for i in 0..num_samples {
if start_sample + i >= samples.len() {
break;
}
let t = i as f64 / SAMPLE_RATE as f64;
let progress = i as f64 / num_samples as f64;
// Frequency interpolation (for slides)
let freq = freq_start + (freq_end - freq_start) * progress;
// Envelope
let env = if i < attack_samples {
i as f32 / attack_samples as f32
} else if i > num_samples - release_samples {
(num_samples - i) as f32 / release_samples as f32
} else {
1.0
};
let mut sample = (t * freq * 2.0 * std::f64::consts::PI).sin() as f32;
// Optional detuned second oscillator for warmth
if let Some(detune_cents) = tone.detune {
let freq2 = freq * (2.0_f64).powf(detune_cents / 1200.0);
sample += (t * freq2 * 2.0 * std::f64::consts::PI).sin() as f32;
sample *= 0.5; // normalize
}
sample *= env * vol;
let val = (sample * 32000.0) as i16;
samples[start_sample + i] = samples[start_sample + i].saturating_add(val);
}
offset_ms += tone.duration_ms;
}
// Build WAV in memory
let data_size = (samples.len() * 2) as u32;
let file_size = 36 + data_size;
let mut wav = Vec::with_capacity(file_size as usize + 8);
// RIFF header
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&file_size.to_le_bytes());
wav.extend_from_slice(b"WAVE");
// fmt chunk
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes()); // chunk size
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
wav.extend_from_slice(&1u16.to_le_bytes()); // mono
wav.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
wav.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); // byte rate
wav.extend_from_slice(&2u16.to_le_bytes()); // block align
wav.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
// data chunk
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_size.to_le_bytes());
for s in &samples {
wav.extend_from_slice(&s.to_le_bytes());
}
// Try available audio players in order
for cmd in &["paplay", "pw-play", "aplay"] {
if let Ok(mut child) = std::process::Command::new(cmd)
.arg("--")
.arg("/dev/stdin")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
let _ = stdin.write_all(&wav);
}
let _ = child.wait();
return;
}
}
}

View File

@@ -5,6 +5,13 @@ use tauri::Manager;
mod database; mod database;
mod commands; mod commands;
#[cfg(target_os = "windows")]
#[path = "os_detection_windows.rs"]
mod os_detection;
#[cfg(target_os = "linux")]
#[path = "os_detection_linux.rs"]
mod os_detection; mod os_detection;
pub struct AppState { pub struct AppState {
@@ -13,16 +20,73 @@ pub struct AppState {
} }
fn get_data_dir() -> PathBuf { fn get_data_dir() -> PathBuf {
let exe_path = std::env::current_exe().unwrap(); // On Linux AppImage: $APPIMAGE points to the .AppImage file itself.
let data_dir = exe_path.parent().unwrap().join("data"); // Store data next to the AppImage so it's fully portable.
let base = if let Ok(appimage_path) = std::env::var("APPIMAGE") {
PathBuf::from(appimage_path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_exe().unwrap().parent().unwrap().to_path_buf())
} else {
std::env::current_exe().unwrap().parent().unwrap().to_path_buf()
};
let data_dir = base.join("data");
std::fs::create_dir_all(&data_dir).ok(); std::fs::create_dir_all(&data_dir).ok();
data_dir data_dir
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
/// On Linux AppImage, install a .desktop file and icon into the user's local
/// XDG directories so GNOME/KDE can show the correct dock icon. Also cleans up
/// stale entries if the AppImage has been moved or deleted.
#[cfg(target_os = "linux")]
fn install_desktop_entry() {
let appimage_path = match std::env::var("APPIMAGE") {
Ok(p) => p,
Err(_) => return, // Not running as AppImage
};
let appdir = std::env::var("APPDIR").unwrap_or_default();
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => return,
};
// Install .desktop file
let apps_dir = format!("{home}/.local/share/applications");
std::fs::create_dir_all(&apps_dir).ok();
let desktop_content = format!(
"[Desktop Entry]\n\
Name=ZeroClock\n\
Comment=Local time tracking with invoicing\n\
Exec={appimage_path}\n\
Icon=zeroclock\n\
Type=Application\n\
Terminal=false\n\
StartupWMClass=zeroclock\n\
Categories=Office;ProjectManagement;\n"
);
std::fs::write(format!("{apps_dir}/zeroclock.desktop"), &desktop_content).ok();
// Install icons from the AppImage's bundled hicolor theme
if !appdir.is_empty() {
for size in &["32x32", "128x128", "256x256@2"] {
let src = format!("{appdir}/usr/share/icons/hicolor/{size}/apps/zeroclock.png");
if std::path::Path::new(&src).exists() {
let dest_dir = format!("{home}/.local/share/icons/hicolor/{size}/apps");
std::fs::create_dir_all(&dest_dir).ok();
std::fs::copy(&src, format!("{dest_dir}/zeroclock.png")).ok();
}
}
}
}
pub fn run() { pub fn run() {
env_logger::init(); env_logger::init();
#[cfg(target_os = "linux")]
install_desktop_entry();
let data_dir = get_data_dir(); let data_dir = get_data_dir();
let db_path = data_dir.join("timetracker.db"); let db_path = data_dir.join("timetracker.db");
@@ -154,8 +218,62 @@ pub fn run() {
commands::update_recurring_invoice, commands::update_recurring_invoice,
commands::delete_recurring_invoice, commands::delete_recurring_invoice,
commands::check_recurring_invoices, commands::check_recurring_invoices,
commands::quit_app,
commands::get_platform,
commands::play_sound,
]) ])
.setup(|app| { .setup(|app| {
// On Wayland, `decorations: false` in tauri.conf.json is ignored due to a
// GTK bug. We set an empty CSD titlebar so the compositor doesn't add
// rectangular SSD, then strip GTK's default CSD shadow/border via CSS.
// See: https://github.com/tauri-apps/tauri/issues/6562
#[cfg(target_os = "linux")]
{
use gtk::prelude::{CssProviderExt, GtkWindowExt, WidgetExt};
if let Some(window) = app.get_webview_window("main") {
if let Ok(gtk_window) = window.gtk_window() {
// Strip GTK's CSD shadow and border from the decoration node
let provider = gtk::CssProvider::new();
provider.load_from_data(b"\
decoration { \
box-shadow: none; \
margin: 0; \
border: none; \
} \
").ok();
if let Some(screen) = WidgetExt::screen(&gtk_window) {
gtk::StyleContext::add_provider_for_screen(
&screen,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
// Set an empty zero-height CSD titlebar so the compositor
// doesn't add rectangular server-side decorations.
// Suppress the harmless "called on a realized window" GTK warning
// by briefly redirecting stderr to /dev/null.
let empty = gtk::Box::new(gtk::Orientation::Horizontal, 0);
empty.set_size_request(-1, 0);
empty.set_visible(true);
unsafe {
extern "C" { fn dup(fd: i32) -> i32; fn dup2(fd: i32, fd2: i32) -> i32; fn close(fd: i32) -> i32; }
let saved = dup(2);
if let Ok(devnull) = std::fs::File::open("/dev/null") {
use std::os::unix::io::AsRawFd;
dup2(devnull.as_raw_fd(), 2);
gtk_window.set_titlebar(Some(&empty));
dup2(saved, 2);
} else {
gtk_window.set_titlebar(Some(&empty));
}
if saved >= 0 { close(saved); }
}
}
}
}
#[cfg(desktop)] #[cfg(desktop)]
{ {
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};

View File

@@ -3,6 +3,166 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
/// Probe EGL: load libEGL, get a display, initialize it, and verify that at
/// least one usable RGBA config exists. Returns true only if the full
/// sequence succeeds - this catches missing drivers, Mesa/NVIDIA mismatches,
/// and broken configs that would give WebKitGTK a black window.
/// Uses dlopen so there's no link-time dependency on EGL.
#[cfg(target_os = "linux")]
fn egl_display_available() -> bool {
extern "C" {
fn dlopen(filename: *const std::ffi::c_char, flags: i32) -> *mut std::ffi::c_void;
fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::ffi::c_char) -> *mut std::ffi::c_void;
fn dlclose(handle: *mut std::ffi::c_void) -> i32;
}
const RTLD_LAZY: i32 = 1;
macro_rules! sym {
($handle:expr, $name:literal) => {{
let s = dlsym($handle, concat!($name, "\0").as_ptr() as *const _);
if s.is_null() { dlclose($handle); return false; }
s
}};
}
unsafe {
let handle = dlopen(b"libEGL.so.1\0".as_ptr() as *const _, RTLD_LAZY);
if handle.is_null() {
return false;
}
// Resolve the three functions we need
let get_display: extern "C" fn(usize) -> usize =
std::mem::transmute(sym!(handle, "eglGetDisplay"));
let initialize: extern "C" fn(usize, *mut i32, *mut i32) -> u32 =
std::mem::transmute(sym!(handle, "eglInitialize"));
let choose_config: extern "C" fn(usize, *const i32, *mut usize, i32, *mut i32) -> u32 =
std::mem::transmute(sym!(handle, "eglChooseConfig"));
let terminate: extern "C" fn(usize) -> u32 =
std::mem::transmute(sym!(handle, "eglTerminate"));
// Step 1: Get display
let display = get_display(0); // EGL_DEFAULT_DISPLAY
if display == 0 {
dlclose(handle);
return false;
}
// Step 2: Initialize
let mut major: i32 = 0;
let mut minor: i32 = 0;
if initialize(display, &mut major, &mut minor) == 0 {
dlclose(handle);
return false;
}
// Step 3: Check for a usable RGBA config
// EGL_RED_SIZE=0x3024 EGL_GREEN_SIZE=0x3023 EGL_BLUE_SIZE=0x3022
// EGL_ALPHA_SIZE=0x3021 EGL_SURFACE_TYPE=0x3033 EGL_WINDOW_BIT=0x0004
// EGL_RENDERABLE_TYPE=0x3040 EGL_OPENGL_ES2_BIT=0x0004 EGL_NONE=0x3038
let attribs: [i32; 15] = [
0x3024, 8, // RED 8
0x3023, 8, // GREEN 8
0x3022, 8, // BLUE 8
0x3021, 8, // ALPHA 8
0x3033, 0x0004, // SURFACE_TYPE = WINDOW
0x3040, 0x0004, // RENDERABLE_TYPE = ES2
0x3038, // NONE
0, 0, // padding
];
let mut config: usize = 0;
let mut num_configs: i32 = 0;
let ok = choose_config(
display,
attribs.as_ptr(),
&mut config,
1,
&mut num_configs,
);
terminate(display);
dlclose(handle);
ok != 0 && num_configs > 0
}
}
fn main() { fn main() {
// The AppImage's linuxdeploy-plugin-gtk.sh forces GDK_BACKEND=x11 as a
// safety default, but XWayland on NVIDIA breaks WebKitGTK input events
// (no clicks, no scroll) and client-side decorations.
// Override to native Wayland which works correctly on modern NVIDIA drivers.
// See: https://github.com/tauri-apps/tauri/issues/11790
#[cfg(target_os = "linux")]
{
if std::env::var("WAYLAND_DISPLAY").is_ok() || std::env::var("XDG_SESSION_TYPE").map(|v| v == "wayland").unwrap_or(false) {
std::env::set_var("GDK_BACKEND", "wayland");
}
// When running as an AppImage, prevent GTK from loading the host's
// GIO modules (gvfs, dconf, libproxy). These are compiled against the
// host's newer glib and fail with "undefined symbol" errors when loaded
// into the AppImage's bundled older glib.
// These modules are optional - gvfs (virtual filesystems), dconf
// (desktop settings), libproxy (proxy config) - none are needed here.
if std::env::var("APPIMAGE").is_ok() {
if let Ok(appdir) = std::env::var("APPDIR") {
let gio_path = format!("{}/usr/lib/x86_64-linux-gnu/gio/modules", appdir);
if std::path::Path::new(&gio_path).exists() {
std::env::set_var("GIO_MODULE_DIR", &gio_path);
} else {
// No bundled GIO modules - point to empty dir to skip system ones
std::env::set_var("GIO_MODULE_DIR", &appdir);
}
}
}
// Disable the DMA-BUF renderer in AppImage builds.
// WebKitGTK 2.42+ defaults to DMA-BUF for framebuffers, but inside an
// AppImage the bundled Mesa often can't construct them against the host's
// GPU drivers - producing a completely black window with zero errors.
// This falls back to shared-memory buffers; GPU compositing still works,
// visual/performance difference is negligible for a desktop app.
// Override: WEBKIT_DISABLE_DMABUF_RENDERER=0
// See: https://github.com/tauri-apps/tauri/issues/13183
if std::env::var("APPIMAGE").is_ok()
&& std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err()
{
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
// Auto-detect EGL failures for AppImage builds.
// If EGL can't even initialize or find a usable config, also disable
// GPU compositing entirely (more aggressive than the DMABUF fix above).
// Override: WEBKIT_DISABLE_COMPOSITING_MODE=0
if std::env::var("APPIMAGE").is_ok()
&& std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err()
&& !egl_display_available()
{
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
// Ensure GStreamer can find system plugins for WebKitGTK Web Audio.
// The AppImage doesn't bundle GStreamer (bundleMediaFramework: false),
// so point it at the host's plugin directories.
if std::env::var("GST_PLUGIN_SYSTEM_PATH").is_err()
&& std::env::var("GST_PLUGIN_SYSTEM_PATH_1_0").is_err()
{
let paths = [
"/usr/lib/x86_64-linux-gnu/gstreamer-1.0",
"/usr/lib64/gstreamer-1.0",
"/usr/lib/gstreamer-1.0",
];
let existing: Vec<&str> = paths
.iter()
.filter(|p| std::path::Path::new(p).exists())
.copied()
.collect();
if !existing.is_empty() {
std::env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", existing.join(":"));
}
}
}
zeroclock_lib::run(); zeroclock_lib::run();
} }

View File

@@ -0,0 +1,537 @@
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct WindowInfo {
pub exe_name: String,
pub exe_path: String,
pub title: String,
pub display_name: String,
pub icon: Option<String>,
}
/// Get system idle time in seconds via D-Bus.
///
/// Tries (in order):
/// 1. org.gnome.Mutter.IdleMonitor.GetIdletime (returns milliseconds)
/// 2. org.freedesktop.ScreenSaver.GetSessionIdleTime (returns seconds)
/// 3. Falls back to 0
pub fn get_system_idle_seconds() -> u64 {
// Try GNOME Mutter IdleMonitor (returns milliseconds)
if let Some(ms) = gdbus_call_u64(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
"GetIdletime",
) {
return ms / 1000;
}
// Try freedesktop ScreenSaver (returns seconds)
if let Some(secs) = gdbus_call_u64(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"GetSessionIdleTime",
) {
return secs;
}
0
}
/// Call a D-Bus method that returns a single uint64/uint32 value via gdbus.
/// Parses the `(uint64 12345,)` or `(uint32 12345,)` output format.
fn gdbus_call_u64(dest: &str, object: &str, interface: &str, method: &str) -> Option<u64> {
let output = Command::new("gdbus")
.args([
"call",
"--session",
"--dest",
dest,
"--object-path",
object,
"--method",
&format!("{}.{}", interface, method),
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
// Format: "(uint64 12345,)" or "(uint32 12345,)"
// Extract the number after "uint64 " or "uint32 "
let s = stdout.trim();
for prefix in &["uint64 ", "uint32 "] {
if let Some(pos) = s.find(prefix) {
let after = &s[pos + prefix.len()..];
let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
return num_str.parse().ok();
}
}
None
}
/// Get the current user's UID from /proc/self/status.
fn get_current_uid() -> u32 {
if let Ok(status) = fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
if let Some(uid_str) = rest.split_whitespace().next() {
if let Ok(uid) = uid_str.parse::<u32>() {
return uid;
}
}
}
}
}
u32::MAX // Unlikely fallback - won't match any process
}
/// Enumerate running processes from /proc.
///
/// Reads /proc/[pid]/exe, /proc/[pid]/comm, and /proc/[pid]/status to build
/// a list of user-space processes. Filters out kernel threads, zombies, and
/// common system daemons. Deduplicates by exe path.
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
let my_uid = get_current_uid();
let mut seen_paths = HashSet::new();
let mut results = Vec::new();
let Ok(entries) = fs::read_dir("/proc") else {
return results;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Only numeric directories (PIDs)
if !name_str.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let pid_path = entry.path();
// Check process belongs to current user
if let Ok(status) = fs::read_to_string(pid_path.join("status")) {
let mut is_our_uid = false;
let mut ppid: u32 = 1;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
if let Some(uid_str) = rest.split_whitespace().next() {
if let Ok(uid) = uid_str.parse::<u32>() {
is_our_uid = uid == my_uid;
}
}
}
if let Some(rest) = line.strip_prefix("PPid:") {
if let Ok(p) = rest.trim().parse::<u32>() {
ppid = p;
}
}
}
if !is_our_uid {
continue;
}
// Skip kernel threads (ppid 0 or 2)
if ppid == 0 || ppid == 2 {
continue;
}
} else {
continue;
}
// Skip zombies (empty cmdline)
if let Ok(cmdline) = fs::read(pid_path.join("cmdline")) {
if cmdline.is_empty() {
continue;
}
}
// Get exe path via symlink
let exe_path = match fs::read_link(pid_path.join("exe")) {
Ok(p) => {
let ps = p.to_string_lossy().to_string();
// Skip deleted executables
if ps.contains(" (deleted)") {
continue;
}
ps
}
Err(_) => continue,
};
// Deduplicate by exe path
if !seen_paths.insert(exe_path.clone()) {
continue;
}
// Get comm (short process name)
let comm = fs::read_to_string(pid_path.join("comm"))
.unwrap_or_default()
.trim()
.to_string();
// Skip common system daemons / background services
if is_system_daemon(&comm, &exe_path) {
continue;
}
let exe_name = std::path::Path::new(&exe_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| comm.clone());
let display_name = prettify_name(&exe_name);
results.push(WindowInfo {
exe_name,
exe_path,
title: String::new(),
display_name,
icon: None,
});
}
// Resolve icons from .desktop files
let icon_map = build_desktop_icon_map();
for info in &mut results {
if let Some(icon_name) = icon_map.get(&info.exe_name) {
if let Some(data_url) = resolve_icon_to_data_url(icon_name) {
info.icon = Some(data_url);
}
}
}
results.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
results
}
/// On Linux we cannot detect window visibility (Wayland security model).
/// Return running processes filtered to likely-GUI apps as a best-effort
/// approximation - the timer will pause only when the tracked process exits.
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
enumerate_running_processes()
.into_iter()
.filter(|w| is_likely_gui_app(&w.exe_path, &w.exe_name))
.collect()
}
/// Heuristic: returns true for processes that are likely background daemons.
fn is_system_daemon(comm: &str, exe_path: &str) -> bool {
const DAEMON_NAMES: &[&str] = &[
"systemd",
"dbus-daemon",
"dbus-broker",
"pipewire",
"pipewire-pulse",
"wireplumber",
"pulseaudio",
"xdg-desktop-portal",
"xdg-document-portal",
"xdg-permission-store",
"gvfsd",
"gvfs-udisks2-volume-monitor",
"at-spi-bus-launcher",
"at-spi2-registryd",
"gnome-keyring-daemon",
"ssh-agent",
"gpg-agent",
"polkitd",
"gsd-",
"evolution-data-server",
"evolution-calendar-factory",
"evolution-addressbook-factory",
"tracker-miner-fs",
"tracker-extract",
"ibus-daemon",
"ibus-x11",
"ibus-portal",
"ibus-extension-gtk3",
"fcitx5",
"goa-daemon",
"goa-identity-service",
"xdg-desktop-portal-gnome",
"xdg-desktop-portal-gtk",
"xdg-desktop-portal-kde",
"xdg-desktop-portal-wlr",
];
// Exact match
if DAEMON_NAMES.contains(&comm) {
return true;
}
// Prefix match (e.g. gsd-*)
for prefix in &["gsd-", "gvfs", "xdg-desktop-portal"] {
if comm.starts_with(prefix) {
return true;
}
}
// System paths
if exe_path.starts_with("/usr/libexec/")
|| exe_path.starts_with("/usr/lib/systemd/")
|| exe_path.starts_with("/usr/lib/polkit")
{
return true;
}
false
}
/// Heuristic: returns true for processes that are likely GUI applications.
fn is_likely_gui_app(exe_path: &str, exe_name: &str) -> bool {
// Common GUI app locations
let gui_paths = ["/usr/bin/", "/usr/local/bin/", "/opt/", "/snap/", "/flatpak/"];
let in_gui_path = gui_paths.iter().any(|p| exe_path.starts_with(p))
|| exe_path.contains("/AppRun")
|| exe_path.contains(".AppImage");
// Home directory apps (Electron, AppImage, etc.)
let in_home = exe_path.contains("/.local/") || exe_path.contains("/home/");
if !in_gui_path && !in_home {
return false;
}
// Exclude known CLI-only tools
const CLI_TOOLS: &[&str] = &[
"bash", "sh", "zsh", "fish", "dash", "csh", "tcsh",
"cat", "ls", "grep", "find", "sed", "awk", "sort", "cut",
"curl", "wget", "ssh", "scp", "rsync",
"git", "make", "cmake", "cargo", "npm", "node", "python", "python3",
"ruby", "perl", "java", "javac",
"top", "htop", "btop", "tmux", "screen",
"sudo", "su", "pkexec",
"journalctl", "systemctl",
];
if CLI_TOOLS.contains(&exe_name) {
return false;
}
true
}
/// Build a map from exe_name → icon_name by scanning .desktop files.
fn build_desktop_icon_map() -> HashMap<String, String> {
let mut map = HashMap::new();
let dirs = [
"/usr/share/applications",
"/usr/local/share/applications",
"/var/lib/flatpak/exports/share/applications",
"/var/lib/snapd/desktop/applications",
];
// Also check ~/.local/share/applications
let home_apps = std::env::var("HOME")
.map(|h| format!("{h}/.local/share/applications"))
.unwrap_or_default();
for dir in dirs.iter().chain(std::iter::once(&home_apps.as_str())) {
if dir.is_empty() {
continue;
}
let Ok(entries) = fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
parse_desktop_entry(&content, &mut map);
}
}
}
map
}
/// Parse a .desktop file and extract exe_name → icon_name mappings.
fn parse_desktop_entry(content: &str, map: &mut HashMap<String, String>) {
let mut icon = None;
let mut exec = None;
let mut in_desktop_entry = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_desktop_entry = trimmed == "[Desktop Entry]";
continue;
}
if !in_desktop_entry {
continue;
}
if let Some(val) = trimmed.strip_prefix("Icon=") {
icon = Some(val.trim().to_string());
} else if let Some(val) = trimmed.strip_prefix("Exec=") {
// Extract the binary name from the Exec line
// Exec can be: /usr/bin/foo, env VAR=val foo, flatpak run ..., etc.
let val = val.trim();
// Strip env prefix
let cmd_part = if val.starts_with("env ") {
// Skip env KEY=VAL pairs
val.split_whitespace()
.skip(1)
.find(|s| !s.contains('='))
.unwrap_or("")
} else {
val.split_whitespace().next().unwrap_or("")
};
// Get just the filename
if let Some(name) = Path::new(cmd_part).file_name() {
exec = Some(name.to_string_lossy().to_string());
}
}
}
if let (Some(exe_name), Some(icon_name)) = (exec, icon) {
if !exe_name.is_empty() && !icon_name.is_empty() {
map.entry(exe_name).or_insert(icon_name);
}
}
}
/// Resolve an icon name to a base64 data URL.
/// Handles both absolute paths and theme icon names.
fn resolve_icon_to_data_url(icon_name: &str) -> Option<String> {
// If it's an absolute path, read directly
if icon_name.starts_with('/') {
return read_icon_file(Path::new(icon_name));
}
// Search hicolor theme at standard sizes (prefer smaller for 20x20 display)
let sizes = ["32x32", "48x48", "24x24", "64x64", "scalable", "128x128", "256x256"];
let theme_dirs = [
"/usr/share/icons/hicolor",
"/usr/share/pixmaps",
];
// Also check user theme
let home_icons = std::env::var("HOME")
.map(|h| format!("{h}/.local/share/icons/hicolor"))
.unwrap_or_default();
// Try hicolor theme with size variants
for base in theme_dirs.iter().map(|s| s.to_string()).chain(
if home_icons.is_empty() { None } else { Some(home_icons.clone()) }
) {
if base.ends_with("pixmaps") {
// /usr/share/pixmaps has flat layout
for ext in &["png", "svg", "xpm"] {
let path = format!("{base}/{icon_name}.{ext}");
if let Some(url) = read_icon_file(Path::new(&path)) {
return Some(url);
}
}
} else {
for size in &sizes {
for ext in &["png", "svg"] {
let path = format!("{base}/{size}/apps/{icon_name}.{ext}");
if let Some(url) = read_icon_file(Path::new(&path)) {
return Some(url);
}
}
}
}
}
None
}
/// Read an icon file and return it as a base64 data URL.
/// Supports PNG and SVG.
fn read_icon_file(path: &Path) -> Option<String> {
if !path.exists() {
return None;
}
let ext = path.extension()?.to_str()?;
let data = fs::read(path).ok()?;
if data.is_empty() {
return None;
}
let mime = match ext {
"png" => "image/png",
"svg" => "image/svg+xml",
"xpm" => return None, // XPM isn't useful as a data URL
_ => return None,
};
let mut b64 = String::new();
base64_encode(&data, &mut b64);
Some(format!("data:{mime};base64,{b64}"))
}
/// Base64 encode bytes into a string (no padding variants, standard alphabet).
fn base64_encode(input: &[u8], output: &mut String) {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut i = 0;
let len = input.len();
output.reserve((len + 2) / 3 * 4);
while i + 2 < len {
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8 | input[i + 2] as u32;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
output.push(ALPHABET[(n & 0x3F) as usize] as char);
i += 3;
}
match len - i {
1 => {
let n = (input[i] as u32) << 16;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push('=');
output.push('=');
}
2 => {
let n = (input[i] as u32) << 16 | (input[i + 1] as u32) << 8;
output.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
output.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
output.push('=');
}
_ => {}
}
}
/// Convert an exe name like "gnome-terminal-server" into "Gnome Terminal Server".
fn prettify_name(name: &str) -> String {
// Strip common suffixes
let stripped = name
.strip_suffix("-bin")
.or_else(|| name.strip_suffix(".bin"))
.unwrap_or(name);
stripped
.split(|c: char| c == '-' || c == '_')
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let mut s = first.to_uppercase().to_string();
s.extend(chars);
s
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ZeroClock", "productName": "ZeroClock",
"version": "1.0.0", "version": "1.0.2",
"identifier": "com.localtimetracker.app", "identifier": "com.localtimetracker.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -53,6 +53,11 @@
"webviewInstallMode": { "webviewInstallMode": {
"type": "embedBootstrapper" "type": "embedBootstrapper"
} }
},
"linux": {
"appimage": {
"bundleMediaFramework": false
}
} }
}, },
"plugins": { "plugins": {

View File

@@ -324,6 +324,8 @@ onMounted(async () => {
if (settingsStore.settings.close_to_tray === 'true') { if (settingsStore.settings.close_to_tray === 'true') {
event.preventDefault() event.preventDefault()
await win.hide() await win.hide()
} else {
await win.destroy()
} }
}) })
} catch (e) { } catch (e) {

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useTimerStore } from '../stores/timer' import { useTimerStore } from '../stores/timer'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
@@ -35,7 +36,11 @@ async function toggleMaximize() {
} }
async function close() { async function close() {
await appWindow.close() if (settingsStore.settings.minimize_to_tray === 'true') {
await appWindow.hide()
} else {
await invoke('quit_app')
}
} }
async function startDrag() { async function startDrag() {

View File

@@ -1,5 +1,5 @@
import type { Directive, DirectiveBinding } from 'vue' import type { Directive, DirectiveBinding } from 'vue'
import { getZoomFactor } from '../utils/dropdown' import { getFixedPositionMapping } from '../utils/dropdown'
interface TooltipState { interface TooltipState {
el: HTMLElement el: HTMLElement
@@ -48,32 +48,29 @@ function positionTooltip(state: TooltipState) {
if (!tip) return if (!tip) return
const rect = state.el.getBoundingClientRect() const rect = state.el.getBoundingClientRect()
const zoom = getZoomFactor() const { scaleX, scaleY, offsetX, offsetY } = getFixedPositionMapping()
const margin = 6 const margin = 6
const arrowSize = 4 const arrowSize = 4
// Measure tooltip // Measure tooltip (in viewport pixels)
tip.style.left = '-9999px' tip.style.left = '-9999px'
tip.style.top = '-9999px' tip.style.top = '-9999px'
tip.style.opacity = '0' tip.style.opacity = '0'
const tipRect = tip.getBoundingClientRect() const tipRect = tip.getBoundingClientRect()
const tipW = tipRect.width / zoom const tipW = tipRect.width
const tipH = tipRect.height / zoom const tipH = tipRect.height
const elTop = rect.top / zoom // All in viewport pixels for placement decisions
const elLeft = rect.left / zoom const vpW = window.innerWidth
const elW = rect.width / zoom const vpH = window.innerHeight
const elH = rect.height / zoom
const vpW = window.innerWidth / zoom
const vpH = window.innerHeight / zoom
// Determine placement // Determine placement
let placement = state.placement let placement = state.placement
if (placement === 'auto') { if (placement === 'auto') {
const spaceAbove = elTop const spaceAbove = rect.top
const spaceBelow = vpH - elTop - elH const spaceBelow = vpH - rect.bottom
const spaceRight = vpW - elLeft - elW const spaceRight = vpW - rect.right
const spaceLeft = elLeft const spaceLeft = rect.left
// Prefer top, then bottom, then right, then left // Prefer top, then bottom, then right, then left
if (spaceAbove >= tipH + margin + arrowSize) placement = 'top' if (spaceAbove >= tipH + margin + arrowSize) placement = 'top'
@@ -83,8 +80,9 @@ function positionTooltip(state: TooltipState) {
else placement = 'top' else placement = 'top'
} }
let top = 0 // Calculate position in viewport pixels
let left = 0 let topVP = 0
let leftVP = 0
const arrow = tip.querySelector('[data-arrow]') as HTMLElement const arrow = tip.querySelector('[data-arrow]') as HTMLElement
// Reset arrow classes // Reset arrow classes
@@ -92,37 +90,39 @@ function positionTooltip(state: TooltipState) {
switch (placement) { switch (placement) {
case 'top': case 'top':
top = elTop - tipH - margin topVP = rect.top - tipH - margin
left = elLeft + elW / 2 - tipW / 2 leftVP = rect.left + rect.width / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2' arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-t-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);' arrow.style.cssText = 'bottom: -4px; border-top-color: var(--color-bg-elevated);'
break break
case 'bottom': case 'bottom':
top = elTop + elH + margin topVP = rect.bottom + margin
left = elLeft + elW / 2 - tipW / 2 leftVP = rect.left + rect.width / 2 - tipW / 2
arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2' arrow.className = 'absolute w-0 h-0 border-x-4 border-x-transparent border-b-4 left-1/2 -translate-x-1/2'
arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);' arrow.style.cssText = 'top: -4px; border-bottom-color: var(--color-bg-elevated);'
break break
case 'right': case 'right':
top = elTop + elH / 2 - tipH / 2 topVP = rect.top + rect.height / 2 - tipH / 2
left = elLeft + elW + margin leftVP = rect.right + margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2' arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-r-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);' arrow.style.cssText = 'left: -4px; border-right-color: var(--color-bg-elevated);'
break break
case 'left': case 'left':
top = elTop + elH / 2 - tipH / 2 topVP = rect.top + rect.height / 2 - tipH / 2
left = elLeft - tipW - margin leftVP = rect.left - tipW - margin
arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2' arrow.className = 'absolute w-0 h-0 border-y-4 border-y-transparent border-l-4 top-1/2 -translate-y-1/2'
arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);' arrow.style.cssText = 'right: -4px; border-left-color: var(--color-bg-elevated);'
break break
} }
// Clamp to viewport // Clamp to viewport
left = Math.max(4, Math.min(left, vpW - tipW - 4)) leftVP = Math.max(4, Math.min(leftVP, vpW - tipW - 4))
top = Math.max(4, Math.min(top, vpH - tipH - 4)) topVP = Math.max(4, Math.min(topVP, vpH - tipH - 4))
tip.style.left = `${left}px` // Convert viewport pixels to CSS pixels for position:fixed inside #app
tip.style.top = `${top}px` // (handles coordinate mapping differences between Chromium and WebKitGTK)
tip.style.left = `${(leftVP - offsetX) / scaleX}px`
tip.style.top = `${(topVP - offsetY) / scaleY}px`
tip.style.opacity = '1' tip.style.opacity = '1'
} }

View File

@@ -111,6 +111,8 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: auto; overflow: auto;
background-color: var(--color-bg-base);
overflow: hidden;
} }
body { body {

View File

@@ -1,3 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
export type SoundEvent = export type SoundEvent =
| 'timer_start' | 'timer_start'
| 'timer_stop' | 'timer_stop'
@@ -41,9 +43,59 @@ const DEFAULT_SETTINGS: AudioSettings = {
events: { ...DEFAULT_EVENTS }, events: { ...DEFAULT_EVENTS },
} }
// Tone description for the Rust backend
interface SoundTone {
freq: number
duration_ms: number
delay_ms: number
freq_end?: number
detune?: number
}
// Map each sound event to its tone sequence (mirrors the Web Audio synthesis)
const TONE_MAP: Record<SoundEvent, SoundTone[]> = {
timer_start: [
{ freq: 523, duration_ms: 100, delay_ms: 0, detune: 3 },
{ freq: 659, duration_ms: 150, delay_ms: 10, detune: 3 },
],
timer_stop: [
{ freq: 784, duration_ms: 250, delay_ms: 0, freq_end: 523 },
],
timer_pause: [
{ freq: 440, duration_ms: 120, delay_ms: 0 },
],
timer_resume: [
{ freq: 523, duration_ms: 120, delay_ms: 0 },
],
idle_alert: [
{ freq: 880, duration_ms: 80, delay_ms: 0 },
{ freq: 880, duration_ms: 80, delay_ms: 60 },
],
goal_reached: [
{ freq: 523, duration_ms: 120, delay_ms: 0, detune: 3 },
{ freq: 659, duration_ms: 120, delay_ms: 10, detune: 3 },
{ freq: 784, duration_ms: 120, delay_ms: 10, detune: 3 },
],
break_reminder: [
{ freq: 659, duration_ms: 200, delay_ms: 0 },
],
}
class AudioEngine { class AudioEngine {
private ctx: AudioContext | null = null private ctx: AudioContext | null = null
private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } } private settings: AudioSettings = { ...DEFAULT_SETTINGS, events: { ...DEFAULT_SETTINGS.events } }
private _isLinux: boolean | null = null
private async isLinux(): Promise<boolean> {
if (this._isLinux === null) {
try {
this._isLinux = (await invoke('get_platform')) === 'linux'
} catch {
this._isLinux = false
}
}
return this._isLinux
}
private ensureContext(): AudioContext { private ensureContext(): AudioContext {
if (!this.ctx) { if (!this.ctx) {
@@ -81,7 +133,17 @@ class AudioEngine {
this.synthesize(event) this.synthesize(event)
} }
private synthesize(event: SoundEvent) { private async synthesize(event: SoundEvent) {
// On Linux, use the Rust backend which plays via paplay/pw-play/aplay
if (await this.isLinux()) {
const tones = TONE_MAP[event]
if (tones) {
invoke('play_sound', { tones, volume: this.gain }).catch(() => {})
}
return
}
// On other platforms, use Web Audio API
switch (event) { switch (event) {
case 'timer_start': case 'timer_start':
this.playTimerStart() this.playTimerStart()

View File

@@ -9,6 +9,23 @@ export interface ImportEntry {
tags?: string[] tags?: string[]
} }
export type DateFormat = 'DMY' | 'MDY'
export type DateDetectResult = DateFormat | 'ambiguous'
export function detectDateFormat(rows: string[][], dateColIndex: number): DateDetectResult {
if (dateColIndex < 0) return 'ambiguous'
for (let i = 1; i < rows.length; i++) {
const val = (rows[i]?.[dateColIndex] || '').trim()
const match = val.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
if (!match) continue
const a = parseInt(match[1])
const b = parseInt(match[2])
if (a > 12) return 'DMY'
if (b > 12) return 'MDY'
}
return 'ambiguous'
}
export function parseCSV(text: string): string[][] { export function parseCSV(text: string): string[][] {
const lines = text.split('\n').filter(l => l.trim()) const lines = text.split('\n').filter(l => l.trim())
return lines.map(line => { return lines.map(line => {
@@ -31,6 +48,7 @@ export function parseCSV(text: string): string[][] {
} }
export function parseDurationString(dur: string): number { export function parseDurationString(dur: string): number {
if (!dur) return 0
// Handle HH:MM:SS, HH:MM, or decimal hours // Handle HH:MM:SS, HH:MM, or decimal hours
if (dur.includes(':')) { if (dur.includes(':')) {
const parts = dur.split(':').map(Number) const parts = dur.split(':').map(Number)
@@ -42,26 +60,160 @@ export function parseDurationString(dur: string): number {
return 0 return 0
} }
export function mapTogglCSV(rows: string[][]): ImportEntry[] { // Parse locale-variable date strings into YYYY-MM-DD
// Toggl CSV format: Description, Project, Client, Task, Tag, Start date, Start time, End date, End time, Duration function parseFlexDate(dateStr: string, formatHint?: DateFormat): string {
const header = rows[0].map(h => h.toLowerCase()) if (!dateStr) return new Date().toISOString().split('T')[0]
const descIdx = header.findIndex(h => h.includes('description')) const s = dateStr.trim()
const projIdx = header.findIndex(h => h.includes('project'))
const clientIdx = header.findIndex(h => h.includes('client')) // YYYY-MM-DD (ISO)
const taskIdx = header.findIndex(h => h.includes('task')) if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(s)) {
const tagIdx = header.findIndex(h => h.includes('tag')) const [y, m, d] = s.split('-')
const startDateIdx = header.findIndex(h => h.includes('start date')) return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
const startTimeIdx = header.findIndex(h => h.includes('start time')) }
const durationIdx = header.findIndex(h => h.includes('duration'))
// Slash or dot separated: could be MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY
const match = s.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{2,4})$/)
if (match) {
let [, a, b, year] = match
if (year.length === 2) year = '20' + year
// If first part > 12, must be day-first (DD/MM/YYYY)
if (parseInt(a) > 12) return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
// If second part > 12, must be month-first (MM/DD/YYYY)
if (parseInt(b) > 12) return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
// Use format hint if provided
if (formatHint === 'DMY') return `${year}-${b.padStart(2, '0')}-${a.padStart(2, '0')}`
// Default to MDY
return `${year}-${a.padStart(2, '0')}-${b.padStart(2, '0')}`
}
// Last resort: try Date constructor
const d = new Date(s)
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0]
return new Date().toISOString().split('T')[0]
}
// Parse 12h or 24h time strings into HH:MM:SS
function parseFlexTime(timeStr: string): string {
if (!timeStr) return '00:00:00'
const s = timeStr.trim()
// 12-hour: "1:00 PM", "11:30:00 AM"
const ampm = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$/i)
if (ampm) {
let hour = parseInt(ampm[1])
const min = ampm[2]
const sec = ampm[3] || '00'
const period = ampm[4].toUpperCase()
if (period === 'PM' && hour !== 12) hour += 12
if (period === 'AM' && hour === 12) hour = 0
return `${hour.toString().padStart(2, '0')}:${min}:${sec}`
}
// 24-hour: "13:00", "09:30:00"
const h24 = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (h24) {
return `${h24[1].padStart(2, '0')}:${h24[2]}:${h24[3] || '00'}`
}
return '00:00:00'
}
function safeDateTime(date: string, time?: string, dateFormat?: DateFormat): string {
const d = parseFlexDate(date, dateFormat)
const t = time ? parseFlexTime(time) : '00:00:00'
return `${d}T${t}`
}
export function findCol(header: string[], ...terms: string[]): number {
for (const term of terms) {
const idx = header.findIndex(h => h === term)
if (idx >= 0) return idx
}
for (const term of terms) {
const idx = header.findIndex(h => h.includes(term))
if (idx >= 0) return idx
}
return -1
}
function col(row: string[], idx: number): string {
return idx >= 0 ? (row[idx] || '') : ''
}
export function mapTogglCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const tagIdx = findCol(header, 'tags', 'tag')
const startDateIdx = findCol(header, 'start date')
const startTimeIdx = findCol(header, 'start time')
const durationIdx = findCol(header, 'duration')
return rows.slice(1).map(row => ({ return rows.slice(1).map(row => ({
project_name: row[projIdx] || 'Imported', project_name: col(row, projIdx) || 'Imported',
client_name: clientIdx >= 0 ? row[clientIdx] : undefined, client_name: col(row, clientIdx) || undefined,
task_name: taskIdx >= 0 ? row[taskIdx] : undefined, task_name: col(row, taskIdx) || undefined,
description: descIdx >= 0 ? row[descIdx] : undefined, description: col(row, descIdx) || undefined,
start_time: combineDateTime(row[startDateIdx], row[startTimeIdx]), start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
duration: durationIdx >= 0 ? parseDurationString(row[durationIdx]) : 0, duration: parseDurationString(col(row, durationIdx)),
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()) : undefined, tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
}))
}
export function mapClockifyCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const descIdx = findCol(header, 'description')
const projIdx = findCol(header, 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const tagIdx = findCol(header, 'tags', 'tag')
const startDateIdx = findCol(header, 'start date')
const startTimeIdx = findCol(header, 'start time')
// Prefer decimal duration - always a simple number, no format ambiguity
const durDecIdx = header.findIndex(h => h.includes('duration (decimal)'))
const durHIdx = header.findIndex(h => h.includes('duration (h)'))
const durPlainIdx = header.findIndex(h => h === 'duration')
return rows.slice(1).map(row => {
let duration = 0
if (durDecIdx >= 0 && row[durDecIdx]) {
duration = Math.round(parseFloat(row[durDecIdx]) * 3600)
} else if (durHIdx >= 0 && row[durHIdx]) {
duration = parseDurationString(row[durHIdx])
} else if (durPlainIdx >= 0 && row[durPlainIdx]) {
duration = parseDurationString(row[durPlainIdx])
}
return {
project_name: col(row, projIdx) || 'Imported',
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, descIdx) || undefined,
start_time: safeDateTime(col(row, startDateIdx), col(row, startTimeIdx) || undefined, dateFormat),
duration,
tags: tagIdx >= 0 && row[tagIdx] ? row[tagIdx].split(',').map(t => t.trim()).filter(Boolean) : undefined,
}
})
}
export function mapHarvestCSV(rows: string[][], dateFormat?: DateFormat): ImportEntry[] {
const header = rows[0].map(h => h.toLowerCase().trim())
const dateIdx = findCol(header, 'date')
const projIdx = header.findIndex(h => h === 'project')
const clientIdx = findCol(header, 'client')
const taskIdx = findCol(header, 'task')
const notesIdx = findCol(header, 'notes')
const hoursIdx = header.findIndex(h => h === 'hours')
return rows.slice(1).map(row => ({
project_name: col(row, projIdx) || 'Imported',
client_name: col(row, clientIdx) || undefined,
task_name: col(row, taskIdx) || undefined,
description: col(row, notesIdx) || undefined,
start_time: safeDateTime(col(row, dateIdx), undefined, dateFormat),
duration: hoursIdx >= 0 && row[hoursIdx] ? Math.round(parseFloat(row[hoursIdx]) * 3600) : 0,
})) }))
} }
@@ -75,9 +227,3 @@ export function mapGenericCSV(rows: string[][], mapping: Record<string, number>)
duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0, duration: mapping.duration >= 0 ? parseDurationString(row[mapping.duration]) : 0,
})) }))
} }
function combineDateTime(date: string, time: string): string {
if (!date) return new Date().toISOString()
if (!time) return new Date(date).toISOString()
return new Date(`${date} ${time}`).toISOString()
}

View File

@@ -526,6 +526,11 @@
</div> </div>
</div> </div>
<!-- Linux app tracking notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
On Linux, window visibility cannot be detected. The timer will only pause when the tracked app's process exits entirely.
</p>
<!-- Check Interval --> <!-- Check Interval -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -547,7 +552,7 @@
<div class="border-t border-border-subtle" /> <div class="border-t border-border-subtle" />
<!-- Timeline Recording --> <!-- Timeline Recording -->
<div class="flex items-center justify-between"> <div :class="['flex items-center justify-between', platform === 'linux' && 'opacity-50 pointer-events-none']">
<div> <div>
<p class="text-[0.8125rem] text-text-primary">Record app timeline</p> <p class="text-[0.8125rem] text-text-primary">Record app timeline</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p> <p class="text-[0.6875rem] text-text-tertiary mt-0.5">Capture which apps and windows are active while the timer runs. Data stays local.</p>
@@ -556,6 +561,7 @@
@click="toggleTimelineRecording" @click="toggleTimelineRecording"
role="switch" role="switch"
:aria-checked="timelineRecording" :aria-checked="timelineRecording"
:disabled="platform === 'linux'"
aria-label="Record app timeline" aria-label="Record app timeline"
:class="[ :class="[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150', 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
@@ -570,6 +576,10 @@
/> />
</button> </button>
</div> </div>
<!-- Linux timeline notice -->
<p v-if="platform === 'linux'" class="text-[0.6875rem] text-text-tertiary -mt-1">
Timeline recording is not available on Linux - Wayland's security model prevents detecting the foreground window.
</p>
<!-- Divider --> <!-- Divider -->
<div class="border-t border-border-subtle" /> <div class="border-t border-border-subtle" />
@@ -1445,6 +1455,32 @@
</table> </table>
</div> </div>
<!-- Date format detection -->
<div v-if="importFormat !== 'json' && importFormat !== 'generic'" class="flex items-center gap-3 mb-3">
<template v-if="importDateDetected !== 'ambiguous'">
<span class="text-[0.6875rem] text-text-tertiary">
Date format detected: {{ importDateFormat === 'DMY' ? 'DD/MM/YYYY' : 'MM/DD/YYYY' }}
</span>
<button
@click="importDateDetected = 'ambiguous'"
class="text-[0.6875rem] text-accent hover:text-accent-hover transition-colors"
>
Change
</button>
</template>
<template v-else>
<label class="text-[0.6875rem] text-text-tertiary">Date format:</label>
<div class="w-36">
<AppSelect
v-model="importDateFormat"
:options="dateFormatOptions"
label-key="label"
value-key="value"
/>
</div>
</template>
</div>
<button <button
@click="executeImport" @click="executeImport"
:disabled="isImporting" :disabled="isImporting"
@@ -1541,7 +1577,7 @@ import AppSelect from '../components/AppSelect.vue'
import AppShortcutRecorder from '../components/AppShortcutRecorder.vue' import AppShortcutRecorder from '../components/AppShortcutRecorder.vue'
import AppTimePicker from '../components/AppTimePicker.vue' import AppTimePicker from '../components/AppTimePicker.vue'
import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale' import { LOCALES, getCurrencies, getCurrencySymbol } from '../utils/locale'
import { parseCSV, mapTogglCSV, mapGenericCSV, type ImportEntry } from '../utils/import' import { parseCSV, mapTogglCSV, mapClockifyCSV, mapHarvestCSV, mapGenericCSV, detectDateFormat, findCol, type ImportEntry, type DateFormat, type DateDetectResult } from '../utils/import'
import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts' import { TIMER_FONTS, loadGoogleFont, loadAndApplyTimerFont } from '../utils/fonts'
import { UI_FONTS, loadUIFont } from '../utils/uiFonts' import { UI_FONTS, loadUIFont } from '../utils/uiFonts'
import { useFocusTrap } from '../utils/focusTrap' import { useFocusTrap } from '../utils/focusTrap'
@@ -1648,6 +1684,7 @@ const shortcutToggleTimer = ref('CmdOrCtrl+Shift+T')
const shortcutShowApp = ref('CmdOrCtrl+Shift+Z') const shortcutShowApp = ref('CmdOrCtrl+Shift+Z')
const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N') const shortcutQuickEntry = ref('CmdOrCtrl+Shift+N')
const timelineRecording = ref(false) const timelineRecording = ref(false)
const platform = ref('')
const timerFont = ref('JetBrains Mono') const timerFont = ref('JetBrains Mono')
const timerFontOptions = TIMER_FONTS const timerFontOptions = TIMER_FONTS
const reduceMotion = ref('system') const reduceMotion = ref('system')
@@ -1978,10 +2015,18 @@ const importFileName = ref('')
const importPreview = ref<string[][]>([]) const importPreview = ref<string[][]>([])
const importStatus = ref('') const importStatus = ref('')
const isImporting = ref(false) const isImporting = ref(false)
const importDateDetected = ref<DateDetectResult>('ambiguous')
const importDateFormat = ref<DateFormat>('MDY')
const dateFormatOptions = [
{ label: 'MM/DD/YYYY', value: 'MDY' },
{ label: 'DD/MM/YYYY', value: 'DMY' },
]
const importFormats = [ const importFormats = [
{ label: 'Toggl CSV', value: 'toggl' }, { label: 'Toggl CSV', value: 'toggl' },
{ label: 'Clockify CSV', value: 'clockify' }, { label: 'Clockify CSV', value: 'clockify' },
{ label: 'Harvest CSV', value: 'harvest' },
{ label: 'Generic CSV', value: 'generic' }, { label: 'Generic CSV', value: 'generic' },
{ label: 'ZeroClock JSON', value: 'json' }, { label: 'ZeroClock JSON', value: 'json' },
] ]
@@ -2357,8 +2402,23 @@ async function handleImportFile() {
if (importFormat.value === 'json') { if (importFormat.value === 'json') {
importPreview.value = [] importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} else { } else {
importPreview.value = parseCSV(content).slice(0, 6) const allRows = parseCSV(content)
importPreview.value = allRows.slice(0, 6)
// Detect date format from CSV data
const header = allRows[0]?.map(h => h.toLowerCase().trim()) || []
let dateColIdx = -1
if (importFormat.value === 'harvest') {
dateColIdx = findCol(header, 'date')
} else {
dateColIdx = findCol(header, 'start date')
}
const detected = detectDateFormat(allRows, dateColIdx)
importDateDetected.value = detected
importDateFormat.value = detected === 'ambiguous' ? 'MDY' : detected
} }
} catch (e) { } catch (e) {
console.error('Failed to read file:', e) console.error('Failed to read file:', e)
@@ -2379,8 +2439,13 @@ async function executeImport() {
const rows = parseCSV(importFileContent.value) const rows = parseCSV(importFileContent.value)
let entries: ImportEntry[] let entries: ImportEntry[]
if (importFormat.value === 'toggl' || importFormat.value === 'clockify') { const df = importDateFormat.value
entries = mapTogglCSV(rows) if (importFormat.value === 'toggl') {
entries = mapTogglCSV(rows, df)
} else if (importFormat.value === 'clockify') {
entries = mapClockifyCSV(rows, df)
} else if (importFormat.value === 'harvest') {
entries = mapHarvestCSV(rows, df)
} else { } else {
entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 }) entries = mapGenericCSV(rows, { project: 0, description: 1, start_time: 2, duration: 3, client: -1, task: -1 })
} }
@@ -2392,6 +2457,8 @@ async function executeImport() {
importFileContent.value = '' importFileContent.value = ''
importFileName.value = '' importFileName.value = ''
importPreview.value = [] importPreview.value = []
importDateDetected.value = 'ambiguous'
importDateFormat.value = 'MDY'
} catch (e) { } catch (e) {
importStatus.value = `Error: ${e}` importStatus.value = `Error: ${e}`
} finally { } finally {
@@ -2439,6 +2506,7 @@ async function clearAllData() {
// Load settings on mount // Load settings on mount
onMounted(async () => { onMounted(async () => {
try { platform.value = await invoke('get_platform') } catch { /* non-critical */ }
await settingsStore.fetchSettings() await settingsStore.fetchSettings()
hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0 hourlyRate.value = parseFloat(settingsStore.settings.hourly_rate) || 0
@@ -2468,7 +2536,7 @@ onMounted(async () => {
businessEmail.value = settingsStore.settings.business_email || '' businessEmail.value = settingsStore.settings.business_email || ''
businessPhone.value = settingsStore.settings.business_phone || '' businessPhone.value = settingsStore.settings.business_phone || ''
businessLogo.value = settingsStore.settings.business_logo || '' businessLogo.value = settingsStore.settings.business_logo || ''
timelineRecording.value = settingsStore.settings.timeline_recording === 'on' timelineRecording.value = platform.value !== 'linux' && settingsStore.settings.timeline_recording === 'on'
timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono' timerFont.value = settingsStore.settings.timer_font || 'JetBrains Mono'
reduceMotion.value = settingsStore.settings.reduce_motion || 'system' reduceMotion.value = settingsStore.settings.reduce_motion || 'system'
dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true' dyslexiaMode.value = settingsStore.settings.dyslexia_mode === 'true'