Add automatic background refresh
Some checks are pending
Validate Decky / web-client (push) Waiting to run
Some checks are pending
Validate Decky / web-client (push) Waiting to run
This commit is contained in:
parent
e9d76cc4a2
commit
ee2ef03982
9 changed files with 163 additions and 25 deletions
|
|
@ -20,6 +20,7 @@ Implemented functionality includes:
|
||||||
- active-board filtering and saved board selection;
|
- active-board filtering and saved board selection;
|
||||||
- board creation, renaming, recoloring, and deletion;
|
- board creation, renaming, recoloring, and deletion;
|
||||||
- stack creation, renaming, reordering, and deletion;
|
- stack creation, renaming, reordering, and deletion;
|
||||||
|
- idle-aware background synchronization of boards, stacks, and cards;
|
||||||
- horizontal and vertical layouts saved independently per board;
|
- horizontal and vertical layouts saved independently per board;
|
||||||
- card creation, editing, deletion, due date, description, and color controls;
|
- card creation, editing, deletion, due date, description, and color controls;
|
||||||
- a directly visible completion checkmark that synchronizes Deck's `done` value
|
- a directly visible completion checkmark that synchronizes Deck's `done` value
|
||||||
|
|
|
||||||
4
app/package-lock.json
generated
4
app/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "decky",
|
"name": "decky",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "decky",
|
"name": "decky",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^0.545.0",
|
"@lucide/svelte": "^0.545.0",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "decky",
|
"name": "decky",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --configLoader runner",
|
"dev": "vite --configLoader runner",
|
||||||
|
|
|
||||||
2
app/src-tauri/Cargo.lock
generated
2
app/src-tauri/Cargo.lock
generated
|
|
@ -590,7 +590,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decky"
|
name = "decky"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"keyring",
|
"keyring",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "decky"
|
name = "decky"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
description = "A focused cross-platform client for Nextcloud Deck"
|
description = "A focused cross-platform client for Nextcloud Deck"
|
||||||
authors = ["Elijah Kuntz"]
|
authors = ["Elijah Kuntz"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Decky",
|
"productName": "Decky",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"identifier": "com.elijahkuntz.decky",
|
"identifier": "com.elijahkuntz.decky",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"category": "Productivity",
|
"category": "Productivity",
|
||||||
"shortDescription": "Nextcloud Deck on your desktop",
|
"shortDescription": "Nextcloud Deck on your desktop",
|
||||||
"longDescription": "A focused cross-platform desktop client for viewing and editing Nextcloud Deck boards.",
|
"longDescription": "A focused cross-platform desktop client for viewing and editing Nextcloud Deck boards.",
|
||||||
"copyright": "Copyright © 2026 Elijah Kuntz",
|
"copyright": "Copyright © 2026 Elijah Kuntz",
|
||||||
"windows": {
|
"windows": {
|
||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"type": "downloadBootstrapper"
|
"type": "downloadBootstrapper"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
X
|
X
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { cssColor, normalizeColor, textColor } from './lib/color';
|
import { cssColor, normalizeColor, textColor } from './lib/color';
|
||||||
|
import { shouldAutoRefresh } from './lib/auto-refresh';
|
||||||
import { loadPassword, removePassword, savePassword } from './lib/credentials';
|
import { loadPassword, removePassword, savePassword } from './lib/credentials';
|
||||||
import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
|
import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
|
||||||
import { loadSettings, saveSettings } from './lib/settings';
|
import { loadSettings, saveSettings } from './lib/settings';
|
||||||
|
|
@ -52,7 +53,7 @@
|
||||||
let dragOriginStacks: Stack[] | null = null;
|
let dragOriginStacks: Stack[] | null = null;
|
||||||
let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
|
let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
|
||||||
let reduceMotion = false;
|
let reduceMotion = false;
|
||||||
let currentVersion = '0.3.0';
|
let currentVersion = '0.3.1';
|
||||||
let pendingUpdate: Update | null = null;
|
let pendingUpdate: Update | null = null;
|
||||||
let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
|
let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
|
||||||
let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
|
let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
|
||||||
|
|
@ -63,6 +64,9 @@
|
||||||
| { kind: 'stack'; mode: 'create' | 'edit'; id?: number; title: string; order: number }
|
| { kind: 'stack'; mode: 'create' | 'edit'; id?: number; title: string; order: number }
|
||||||
| null = null;
|
| null = null;
|
||||||
let pendingDeletion: { kind: 'board' | 'stack'; id: number; title: string; cardCount: number } | null = null;
|
let pendingDeletion: { kind: 'board' | 'stack'; id: number; title: string; cardCount: number } | null = null;
|
||||||
|
let lastInteractionAt = Date.now();
|
||||||
|
let lastAutoRefreshAt = 0;
|
||||||
|
let autoRefreshInFlight = false;
|
||||||
const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
|
const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
|
||||||
const [sendCard, receiveCard] = crossfade({
|
const [sendCard, receiveCard] = crossfade({
|
||||||
duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
|
duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
|
||||||
|
|
@ -109,7 +113,7 @@
|
||||||
{ id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
|
{ id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
|
||||||
]}
|
]}
|
||||||
];
|
];
|
||||||
status = 'Homework · 6 cards · Preview data';
|
status = 'Homework · 6 cards · Preview data';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!serverUrl || !username) return;
|
if (!serverUrl || !username) return;
|
||||||
|
|
@ -122,6 +126,17 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const noteInteraction = () => { lastInteractionAt = Date.now(); };
|
||||||
|
const events: (keyof WindowEventMap)[] = ['pointerdown', 'keydown', 'wheel'];
|
||||||
|
for (const event of events) window.addEventListener(event, noteInteraction, { passive: true });
|
||||||
|
const timer = window.setInterval(() => void runIdleRefresh(), 1_000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
for (const event of events) window.removeEventListener(event, noteInteraction);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function friendlyError(error: unknown): string {
|
function friendlyError(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message;
|
if (error instanceof Error) return error.message;
|
||||||
return String(error);
|
return String(error);
|
||||||
|
|
@ -134,7 +149,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState = 'checking';
|
updateState = 'checking';
|
||||||
updateMessage = 'Checking Forgejo for a newer signed release…';
|
updateMessage = 'Checking Forgejo for a newer signed release…';
|
||||||
updateProgress = 0;
|
updateProgress = 0;
|
||||||
pendingUpdate = null;
|
pendingUpdate = null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -155,7 +170,7 @@
|
||||||
async function installUpdate() {
|
async function installUpdate() {
|
||||||
if (!pendingUpdate) return;
|
if (!pendingUpdate) return;
|
||||||
updateState = 'downloading';
|
updateState = 'downloading';
|
||||||
updateMessage = `Downloading Decky ${pendingUpdate.version}…`;
|
updateMessage = `Downloading Decky ${pendingUpdate.version}…`;
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try {
|
try {
|
||||||
|
|
@ -169,7 +184,7 @@
|
||||||
} else if (event.event === 'Finished') {
|
} else if (event.event === 'Finished') {
|
||||||
updateState = 'installing';
|
updateState = 'installing';
|
||||||
updateProgress = 100;
|
updateProgress = 100;
|
||||||
updateMessage = 'Download verified. Installing and restarting Decky…';
|
updateMessage = 'Download verified. Installing and restarting Decky…';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await relaunch();
|
await relaunch();
|
||||||
|
|
@ -181,7 +196,7 @@
|
||||||
|
|
||||||
async function connect(silent = false) {
|
async function connect(silent = false) {
|
||||||
busy = true;
|
busy = true;
|
||||||
status = 'Connecting to Nextcloud…';
|
status = 'Connecting to Nextcloud…';
|
||||||
try {
|
try {
|
||||||
client = await DeckApiClient.connect({ serverUrl, username, appPassword });
|
client = await DeckApiClient.connect({ serverUrl, username, appPassword });
|
||||||
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
|
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
|
||||||
|
|
@ -213,13 +228,13 @@
|
||||||
async function refreshBoard() {
|
async function refreshBoard() {
|
||||||
if (!client || !selectedBoard) return;
|
if (!client || !selectedBoard) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
status = `Loading ${selectedBoard.title}…`;
|
status = `Loading ${selectedBoard.title}…`;
|
||||||
try {
|
try {
|
||||||
stacks = (await client.getStacks(selectedBoard.id))
|
stacks = (await client.getStacks(selectedBoard.id))
|
||||||
.filter((stack) => !stack.deletedAt)
|
.filter((stack) => !stack.deletedAt)
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
|
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
|
||||||
status = `${selectedBoard.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
status = `${selectedBoard.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = friendlyError(error);
|
status = friendlyError(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -227,6 +242,68 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshFromNextcloud(background = false) {
|
||||||
|
if (!client) return;
|
||||||
|
const activeClient = client;
|
||||||
|
const startingBoardId = selectedBoard?.id;
|
||||||
|
const interactionAtStart = lastInteractionAt;
|
||||||
|
if (!background) {
|
||||||
|
busy = true;
|
||||||
|
status = selectedBoard ? `Loading ${selectedBoard.title}…` : 'Refreshing boards…';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const refreshedBoards = (await activeClient.getBoards()).filter((board) => !board.archived && !board.deletedAt);
|
||||||
|
if (client !== activeClient || (background && (selectedBoard?.id !== startingBoardId || lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
|
||||||
|
boards = refreshedBoards;
|
||||||
|
const board = refreshedBoards.find((item) => item.id === startingBoardId) ?? refreshedBoards[0];
|
||||||
|
if (!board) {
|
||||||
|
selectedBoard = null;
|
||||||
|
stacks = [];
|
||||||
|
status = 'Connected, but no active Deck boards were found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedBoard = board;
|
||||||
|
if (settings.selectedBoardId !== board.id) {
|
||||||
|
settings = { ...settings, selectedBoardId: board.id };
|
||||||
|
saveSettings(settings);
|
||||||
|
}
|
||||||
|
const refreshedStacks = (await activeClient.getStacks(board.id))
|
||||||
|
.filter((stack) => !stack.deletedAt)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
if (client !== activeClient || selectedBoard?.id !== board.id || (background && (lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
|
||||||
|
stacks = refreshedStacks;
|
||||||
|
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
|
||||||
|
status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
||||||
|
} catch (error) {
|
||||||
|
status = `${background ? 'Background refresh failed' : 'Refresh failed'}: ${friendlyError(error)}`;
|
||||||
|
} finally {
|
||||||
|
if (!background) busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIdleRefresh() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (autoRefreshInFlight || !shouldAutoRefresh({
|
||||||
|
now,
|
||||||
|
lastInteractionAt,
|
||||||
|
lastRefreshAt: lastAutoRefreshAt,
|
||||||
|
connected: Boolean(client),
|
||||||
|
busy,
|
||||||
|
editing: Boolean(editor || structureEditor || pendingDeletion || settingsOpen),
|
||||||
|
dragging: Boolean(dragKind),
|
||||||
|
visible: document.visibilityState === 'visible'
|
||||||
|
})) return;
|
||||||
|
|
||||||
|
autoRefreshInFlight = true;
|
||||||
|
lastAutoRefreshAt = now;
|
||||||
|
try {
|
||||||
|
await refreshFromNextcloud(true);
|
||||||
|
} finally {
|
||||||
|
autoRefreshInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function canEdit(board: Board | null): boolean {
|
function canEdit(board: Board | null): boolean {
|
||||||
return Boolean(board) && (board?.permissions?.PERMISSION_EDIT ?? true);
|
return Boolean(board) && (board?.permissions?.PERMISSION_EDIT ?? true);
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +503,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCard(card: Card) {
|
async function deleteCard(card: Card) {
|
||||||
if (!client || !selectedBoard || !confirm(`Delete “${card.title}”?`)) return;
|
if (!client || !selectedBoard || !confirm(`Delete “${card.title}�`)) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await client.deleteCard(selectedBoard.id, card.stackId, card.id);
|
await client.deleteCard(selectedBoard.id, card.stackId, card.id);
|
||||||
|
|
@ -599,7 +676,7 @@
|
||||||
const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
|
const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
|
||||||
stacks = await client.reorderStack(board.id, stackId, targetIndex);
|
stacks = await client.reorderStack(board.id, stackId, targetIndex);
|
||||||
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((item) => !item.archived).length ?? 0), 0);
|
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((item) => !item.archived).length ?? 0), 0);
|
||||||
status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Stack order saved`;
|
status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Stack order saved`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stacks = origin;
|
stacks = origin;
|
||||||
|
|
@ -634,7 +711,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} · Decky` : 'Decky'}</title></svelte:head>
|
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} · Decky` : 'Decky'}</title></svelte:head>
|
||||||
|
|
||||||
<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
|
<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
|
||||||
<header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
|
<header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
|
||||||
|
|
@ -654,7 +731,7 @@
|
||||||
<label>Username<input bind:value={username} autocomplete="username" /></label>
|
<label>Username<input bind:value={username} autocomplete="username" /></label>
|
||||||
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
|
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
|
||||||
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
||||||
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : 'Connect'}</button>
|
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : 'Connect'}</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -682,7 +759,7 @@
|
||||||
<button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
|
<button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
|
||||||
<button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
|
<button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-tool" title="Refresh" disabled={!selectedBoard || busy} on:click={refreshBoard}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
|
<button class="sidebar-tool" title="Refresh" disabled={!client || busy} on:click={() => refreshFromNextcloud()}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
|
||||||
<button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
|
<button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -790,7 +867,7 @@
|
||||||
<form class="modal" on:submit|preventDefault={saveCard}>
|
<form class="modal" on:submit|preventDefault={saveCard}>
|
||||||
<header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
|
<header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
|
||||||
<label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
|
<label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
|
||||||
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details…"></textarea></label>
|
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details…"></textarea></label>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
|
<label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
|
||||||
<label>Card color
|
<label>Card color
|
||||||
|
|
@ -866,7 +943,7 @@
|
||||||
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (pendingDeletion = null)}>
|
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (pendingDeletion = null)}>
|
||||||
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
|
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
|
||||||
<header>
|
<header>
|
||||||
<div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete “{pendingDeletion.title}”?</h2></div>
|
<div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete “{pendingDeletion.title}�</h2></div>
|
||||||
<button type="button" class="icon-button" aria-label="Close" on:click={() => (pendingDeletion = null)}><X size={20} /></button>
|
<button type="button" class="icon-button" aria-label="Close" on:click={() => (pendingDeletion = null)}><X size={20} /></button>
|
||||||
</header>
|
</header>
|
||||||
<p class="modal-copy">
|
<p class="modal-copy">
|
||||||
|
|
@ -876,7 +953,7 @@
|
||||||
This permanently deletes the stack and its {pendingDeletion.cardCount} card{pendingDeletion.cardCount === 1 ? '' : 's'} from Nextcloud.
|
This permanently deletes the stack and its {pendingDeletion.cardCount} card{pendingDeletion.cardCount === 1 ? '' : 's'} from Nextcloud.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting…' : `Delete ${pendingDeletion.kind}`}</button></footer>
|
<footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting…' : `Delete ${pendingDeletion.kind}`}</button></footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -895,7 +972,7 @@
|
||||||
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
{#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
|
{#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
|
||||||
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : client ? 'Reconnect' : 'Connect'}</button>
|
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : client ? 'Reconnect' : 'Connect'}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -919,7 +996,7 @@
|
||||||
<button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
|
<button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
|
<button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
|
||||||
{updateState === 'checking' ? 'Checking…' : 'Check for updates'}
|
{updateState === 'checking' ? 'Checking…' : 'Check for updates'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
37
app/src/lib/auto-refresh.test.ts
Normal file
37
app/src/lib/auto-refresh.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AUTO_REFRESH_IDLE_MS, AUTO_REFRESH_REPEAT_MS, shouldAutoRefresh } from './auto-refresh';
|
||||||
|
|
||||||
|
const idleState = {
|
||||||
|
now: AUTO_REFRESH_REPEAT_MS,
|
||||||
|
lastInteractionAt: 0,
|
||||||
|
lastRefreshAt: 0,
|
||||||
|
connected: true,
|
||||||
|
busy: false,
|
||||||
|
editing: false,
|
||||||
|
dragging: false,
|
||||||
|
visible: true
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('automatic refresh scheduling', () => {
|
||||||
|
it('refreshes after the user has been idle long enough', () => {
|
||||||
|
expect(shouldAutoRefresh(idleState)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for ten seconds of inactivity', () => {
|
||||||
|
expect(shouldAutoRefresh({ ...idleState, lastInteractionAt: idleState.now - AUTO_REFRESH_IDLE_MS + 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeatedly refresh more often than the background interval', () => {
|
||||||
|
expect(shouldAutoRefresh({ ...idleState, lastRefreshAt: idleState.now - AUTO_REFRESH_REPEAT_MS + 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['disconnected', { connected: false }],
|
||||||
|
['busy', { busy: true }],
|
||||||
|
['editing', { editing: true }],
|
||||||
|
['dragging', { dragging: true }],
|
||||||
|
['hidden', { visible: false }]
|
||||||
|
])('pauses while %s', (_name, change) => {
|
||||||
|
expect(shouldAutoRefresh({ ...idleState, ...change })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
app/src/lib/auto-refresh.ts
Normal file
23
app/src/lib/auto-refresh.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export const AUTO_REFRESH_IDLE_MS = 10_000;
|
||||||
|
export const AUTO_REFRESH_REPEAT_MS = 30_000;
|
||||||
|
|
||||||
|
export interface AutoRefreshState {
|
||||||
|
now: number;
|
||||||
|
lastInteractionAt: number;
|
||||||
|
lastRefreshAt: number;
|
||||||
|
connected: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
editing: boolean;
|
||||||
|
dragging: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoRefresh(state: AutoRefreshState): boolean {
|
||||||
|
return state.connected
|
||||||
|
&& state.visible
|
||||||
|
&& !state.busy
|
||||||
|
&& !state.editing
|
||||||
|
&& !state.dragging
|
||||||
|
&& state.now - state.lastInteractionAt >= AUTO_REFRESH_IDLE_MS
|
||||||
|
&& state.now - state.lastRefreshAt >= AUTO_REFRESH_REPEAT_MS;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue