Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ Implemented today:
plaintext-vault migration support on desktop.
- Today, Notes, Reminders, Settings, profile switching, and diagnostics UI,
shared between desktop and Android.
- Desktop Zen Mode (focus icon beside pin) shows unfinished tasks from today's
list, including long-running tasks, one at a time. Next cycles through the
unfinished tasks without changing their status; completed tasks leave the ring.
The final screen says "All done for today" only when no unfinished tasks remain.
Zen Mode hides the normal header and fits window height to the task and Next
button (72–180 px limits), leaving a 4 px gap without unused space underneath,
with an exit button at the top right. Exiting restores the previous window size;
compact geometry is not persisted as the normal size. Task titles wrap, and
task lists and rename dialogs use the same thin scrollbar style as Settings.
Task actions stay beside the title when both fit; otherwise they move above it.
Tasks can be renamed from their inline actions
or details dialog; title changes are persisted and included in VDS sync.
- Audio note capture with encrypted desktop audio blobs.
- Reminder alert window with snooze and acknowledge actions.
- VDS sync server: accounts, device linking, operation-log exchange,
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src-tauri/src/commands/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@ use tauri_plugin_opener::OpenerExt;

const PRIVACY_POLICY_URL: &str = "https://focus.proanima.net/privacy";

#[tauri::command]
pub(crate) fn get_zen_mode() -> bool {
crate::zen_window::is_active()
}

#[tauri::command]
pub(crate) fn resize_zen_window(window: tauri::WebviewWindow, height: u32) -> Result<(), String> {
if window.label() != "main" {
return Err("Zen Mode is available in the main window only".into());
}
crate::zen_window::resize_to_content(&window, height)
}

#[tauri::command]
pub(crate) fn set_zen_mode(window: tauri::WebviewWindow, enabled: bool) -> Result<(), String> {
if window.label() != "main" {
return Err("Zen Mode is available in the main window only".into());
}
crate::zen_window::set_mode(&window, enabled)
}

#[tauri::command]
pub(crate) fn open_privacy_policy(app: tauri::AppHandle) -> Result<(), String> {
app.opener()
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src-tauri/src/commands/plan_items.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
use super::trigger_server_sync;
use crate::{db, plan_items, profiles, sync_log};

#[tauri::command]
pub(crate) fn rename_plan_item(
app: tauri::AppHandle,
db: tauri::State<db::Db>,
hlc_state: tauri::State<sync_log::HlcClockState>,
profiles_state: tauri::State<profiles::ProfilesState>,
id: String,
title: String,
) -> Result<plan_items::PlanItemDto, String> {
let mut conn = db.0.lock().map_err(|e| e.to_string())?;
let mut clock = hlc_state.0.lock().map_err(|e| e.to_string())?;
let profile_id = profiles::active_profile_id(&profiles_state)?;
let item = plan_items::rename(&mut conn, &mut clock, &profile_id, &id, &title)
.map_err(|e| e.to_string())?;
drop(clock);
drop(conn);
trigger_server_sync(&app);
Ok(item)
}

#[tauri::command]
pub(crate) fn list_plan_items(
db: tauri::State<db::Db>,
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod sync_status;
#[cfg(feature = "cloud-providers")]
mod sync_tokens;
mod window_state;
mod zen_window;

use serde::Serialize;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
Expand Down Expand Up @@ -332,6 +333,10 @@ pub fn run() {
commands::plan_items::list_plan_items,
commands::plan_items::list_plan_items_range,
commands::plan_items::create_plan_item,
commands::plan_items::rename_plan_item,
commands::overlay::set_zen_mode,
commands::overlay::get_zen_mode,
commands::overlay::resize_zen_window,
commands::plan_items::toggle_plan_item_done,
commands::plan_items::cycle_plan_item_progress,
commands::plan_items::toggle_plan_item_deferred,
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src-tauri/src/plan_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ use crate::sync_log::{self, HlcClock};
use rusqlite::{params, Connection, Row};
use serde::Serialize;

mod rename;
pub use rename::rename;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanItemDto {
Expand Down Expand Up @@ -391,6 +394,37 @@ mod tests {
.unwrap()
}

#[test]
fn rename_preserves_state_and_records_title_patch() {
let mut conn = setup_conn();
let mut clock = test_clock();
let item = create(&mut conn, &mut clock, PROFILE, "Task", TODAY).unwrap();
toggle_long_running(&mut conn, &mut clock, PROFILE, &item.id).unwrap();
cycle_progress(&mut conn, &mut clock, PROFILE, &item.id).unwrap();
let renamed = rename(&mut conn, &mut clock, PROFILE, &item.id, " Updated ").unwrap();
assert_eq!(renamed.title, "Updated");
assert_eq!(renamed.status, "partial");
assert_eq!(renamed.progress_percent, Some(25));
assert!(renamed.is_long_running);
assert_eq!(renamed.plan_date, TODAY);
let patch: String = conn
.query_row(
"SELECT patch FROM sync_operations WHERE entity_id = ?1 ORDER BY hlc DESC LIMIT 1",
params![item.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&patch).unwrap(),
serde_json::json!({"title": "Updated"})
);
let count = operation_count(&conn);
assert!(rename(&mut conn, &mut clock, PROFILE, &item.id, " ").is_err());
assert!(rename(&mut conn, &mut clock, PROFILE, "missing", "Title").is_err());
assert_eq!(operation_count(&conn), count);
assert_eq!(fetch(&conn, &item.id).unwrap().title, "Updated");
}

#[test]
fn create_starts_open_with_date_and_no_progress() {
let mut conn = setup_conn();
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src-tauri/src/plan_items/rename.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use super::{fetch, PlanItemDto};
use crate::sync_log::{self, HlcClock};
use rusqlite::{params, Connection};

pub fn rename(
conn: &mut Connection,
clock: &mut HlcClock,
profile_id: &str,
id: &str,
title: &str,
) -> rusqlite::Result<PlanItemDto> {
let title = title.trim();
if title.is_empty() {
return Err(rusqlite::Error::InvalidParameterName("title".into()));
}
let tx = conn.transaction()?;
fetch(&tx, id)?;
tx.execute(
"UPDATE plan_items SET title = ?1 WHERE id = ?2",
params![title, id],
)?;
let hlc = clock.next(&tx)?;
sync_log::record_operation(
&tx,
&hlc,
profile_id,
"plan_item",
id,
"update",
&serde_json::json!({ "title": title }),
)?;
let dto = fetch(&tx, id)?;
tx.commit()?;
Ok(dto)
}
35 changes: 35 additions & 0 deletions apps/desktop/src-tauri/src/server_sync/apply/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,41 @@ fn stale_remote_update_does_not_overwrite_newer_local_plan_item() -> Result<(),
Ok(())
}

#[test]
fn remote_title_update_renames_plan_item() -> Result<(), String> {
let conn = sync_test_conn();
conn.execute(
"INSERT INTO plan_items
(id, title, status, progress_percent, plan_date, created_at)
VALUES ('task-1', 'Call', 'open', NULL, '2026-07-07', datetime('now'))",
[],
)
.map_err(|e| e.to_string())?;
let rename = RemoteOperation {
device_id: "phone-device".to_string(),
entity_id: "task-1".to_string(),
entity_type: "plan_item".to_string(),
hlc: "2026-07-07T10:00:00.000Z-0000-phone-device".to_string(),
op: "update".to_string(),
operation_id: "remote-rename-op".to_string(),
payload_ciphertext: serde_json::json!({ "title": "Call the bank" }).to_string(),
schema_version: 1,
};

apply_remote_operation(&conn, "profile-1", "desktop-device", &rename, None)?;

let (title, status): (String, String) = conn
.query_row(
"SELECT title, status FROM plan_items WHERE id = 'task-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|e| e.to_string())?;
assert_eq!(title, "Call the bank");
assert_eq!(status, "open");
Ok(())
}

#[test]
fn newer_remote_delete_wins_over_older_local_plan_item() -> Result<(), String> {
let conn = sync_test_conn();
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src-tauri/src/server_sync/apply_entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ pub(super) fn apply_plan_item(
)
}
"update" => {
if let Some(title) = string_field(patch, "title") {
conn.execute(
"UPDATE plan_items SET title = ?1 WHERE id = ?2",
params![title, operation.entity_id],
)
.map_err(|e| e.to_string())?;
}
if let Some(status) = string_field(patch, "status") {
let progress = patch.get("progressPercent").and_then(Value::as_i64);
conn.execute(
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src-tauri/src/window_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,17 @@ pub fn reapply_size_after_scale_change(
data_dir: &Path,
scale_factor: f64,
) {
if crate::zen_window::is_active() {
return;
}
let Some(state) = load_state(data_dir) else {
return;
};
apply_size(window, &state, scale_factor);
}

pub fn save(window: &tauri::WebviewWindow, data_dir: &Path) -> Result<(), String> {
if window.label() != "main" {
if window.label() != "main" || crate::zen_window::is_active() {
return Ok(());
}
let physical_size = window.outer_size().map_err(|e| e.to_string())?;
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src-tauri/src/zen_window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! Temporary desktop geometry; normal window persistence is suspended during Zen Mode.
use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex,
};
use tauri::{LogicalSize, WebviewWindow};

static ACTIVE: AtomicBool = AtomicBool::new(false);
static NORMAL_SIZE: Mutex<Option<LogicalSize<f64>>> = Mutex::new(None);

pub fn is_active() -> bool {
ACTIVE.load(Ordering::SeqCst)
}

fn apply_geometry(window: &WebviewWindow, zen: bool, size: LogicalSize<f64>) -> tauri::Result<()> {
let position = window.outer_position()?;
window.set_max_size(None::<LogicalSize<f64>>)?;
window.set_min_size(Some(LogicalSize::new(
280.0,
if zen { 72.0 } else { 260.0 },
)))?;
window.set_size(size)?;
window.set_max_size(Some(LogicalSize::new(
520.0,
if zen { 180.0 } else { 1200.0 },
)))?;
window.set_position(position)?;
Ok(())
}

pub fn set_mode(window: &WebviewWindow, enabled: bool) -> Result<(), String> {
let mut normal = NORMAL_SIZE
.lock()
.map_err(|_| "window state unavailable".to_string())?;
if enabled == is_active() {
return Ok(());
}
if enabled {
let size = window
.inner_size()
.map_err(|e| e.to_string())?
.to_logical::<f64>(window.scale_factor().map_err(|e| e.to_string())?);
ACTIVE.store(true, Ordering::SeqCst);
if let Err(error) = apply_geometry(window, true, LogicalSize::new(size.width, 96.0)) {
if apply_geometry(window, false, size).is_err() {
log::warn!("failed to restore normal window geometry after Zen transition");
}
ACTIVE.store(false, Ordering::SeqCst);
return Err(error.to_string());
}
*normal = Some(size);
} else if let Some(size) = *normal {
apply_geometry(window, false, size).map_err(|e| e.to_string())?;
ACTIVE.store(false, Ordering::SeqCst);
*normal = None;
}
Ok(())
}

pub fn resize_to_content(window: &WebviewWindow, height: u32) -> Result<(), String> {
let _normal = NORMAL_SIZE
.lock()
.map_err(|_| "window state unavailable".to_string())?;
if !is_active() {
return Ok(());
}
let size = window
.inner_size()
.map_err(|e| e.to_string())?
.to_logical::<f64>(window.scale_factor().map_err(|e| e.to_string())?);
let position = window.outer_position().map_err(|e| e.to_string())?;
window
.set_size(LogicalSize::new(
size.width,
f64::from(height.clamp(72, 180)),
))
.map_err(|e| e.to_string())?;
window.set_position(position).map_err(|e| e.to_string())
}
1 change: 1 addition & 0 deletions apps/desktop/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "./styles/shell.css";
@import "./styles/profile.css";
@import "./styles/plan-items.css";
@import "./styles/zen.css";
@import "./styles/notes.css";
@import "./styles/reminders.css";
@import "./styles/settings.css";
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";

// Раздел 21 ТЗ: "layout snapshots для compact desktop/mobile" — снимок всей
Expand All @@ -12,6 +13,8 @@ vi.mock("./shared/commands", () => {
return {
commands: {
overlay: {
getZenMode: () => Promise.resolve(false),
resizeZenWindow: () => Promise.resolve(),
toggle: reject,
onLayerChanged: () => Promise.resolve(() => {}),
getShortcutStatus: () => Promise.resolve(null),
Expand Down Expand Up @@ -104,6 +107,20 @@ function normalizeDynamicDate(container: HTMLElement) {
}

describe("App layout snapshots", () => {
it("enters Zen Mode from the header and restores navigation on exit", async () => {
isDesktop.mockResolvedValue(true);
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Включить Zen Mode" }));
expect(await screen.findByText("На сегодня всё")).toBeInTheDocument();
expect(screen.queryByText("FocusNook")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Убрать с переднего плана" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Настройки" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Закрыть" })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Добавить дело...")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Выйти из Zen Mode" }));
expect(await screen.findByText("На сегодня пока ничего не запланировано")).toBeInTheDocument();
});
it("matches the desktop shell layout", async () => {
isDesktop.mockResolvedValue(true);
const { container } = render(<App />);
Expand Down
Loading
Loading