ui updates
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Wait/Scheduled not working correctly
|
||||||
|
`Buy milk due:8d wait:5d` still showing up
|
||||||
|
|
||||||
|
# Missing uncomplete feat
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover" />
|
||||||
<meta name="description" content="Mobile-first task management with offline support" />
|
<meta name="description" content="Mobile-first task management with offline support" />
|
||||||
<meta name="theme-color" content="#4f46e5" />
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const tasks = {
|
|||||||
if (filters.tags) {
|
if (filters.tags) {
|
||||||
filters.tags.forEach(tag => params.append('tag', tag));
|
filters.tags.forEach(tag => params.append('tag', tag));
|
||||||
}
|
}
|
||||||
|
if (filters.excludeTags) {
|
||||||
|
filters.excludeTags.forEach(tag => params.append('exclude_tag', tag));
|
||||||
|
}
|
||||||
|
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
* @property {string} [project]
|
* @property {string} [project]
|
||||||
* @property {string} [priority]
|
* @property {string} [priority]
|
||||||
* @property {string[]} [tags]
|
* @property {string[]} [tags]
|
||||||
|
* @property {string[]} [excludeTags]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<script>
|
||||||
|
import { recentFilters, setFilter, removeRecent } from '$lib/stores/filters.js';
|
||||||
|
|
||||||
|
/** @type {() => void} */
|
||||||
|
export let onClose;
|
||||||
|
|
||||||
|
let filterInput = '';
|
||||||
|
|
||||||
|
/** @type {HTMLDialogElement|null} */
|
||||||
|
let dialogEl = null;
|
||||||
|
|
||||||
|
/** @type {HTMLInputElement|null} */
|
||||||
|
let inputEl = null;
|
||||||
|
|
||||||
|
export function open() {
|
||||||
|
filterInput = '';
|
||||||
|
dialogEl?.showModal();
|
||||||
|
// Focus after dialog animation
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const trimmed = filterInput.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
setFilter(trimmed);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
dialogEl?.close();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filter
|
||||||
|
*/
|
||||||
|
function applyRecent(filter) {
|
||||||
|
setFilter(filter);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Event} e
|
||||||
|
* @param {string} filter
|
||||||
|
*/
|
||||||
|
function handleRemoveRecent(e, filter) {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeRecent(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
*/
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {MouseEvent} e
|
||||||
|
*/
|
||||||
|
function handleBackdropClick(e) {
|
||||||
|
if (e.target === dialogEl) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||||
|
<dialog
|
||||||
|
bind:this={dialogEl}
|
||||||
|
class="filter-modal"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Filter</h3>
|
||||||
|
<button class="close-btn" on:click={close} type="button" aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="20" height="20">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={filterInput}
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. +grocer project:home"
|
||||||
|
class="filter-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="apply-btn"
|
||||||
|
on:click={apply}
|
||||||
|
disabled={!filterInput.trim()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $recentFilters.length > 0}
|
||||||
|
<div class="recents">
|
||||||
|
<div class="recents-label">Recent</div>
|
||||||
|
{#each $recentFilters as recent}
|
||||||
|
<button
|
||||||
|
class="recent-row"
|
||||||
|
on:click={() => applyRecent(recent)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="recent-text">{recent}</span>
|
||||||
|
<span
|
||||||
|
class="recent-remove"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:click={(e) => handleRemoveRecent(e, recent)}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && handleRemoveRecent(e, recent)}
|
||||||
|
aria-label="Remove {recent} from recents"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="14" height="14">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-modal {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: auto;
|
||||||
|
max-width: min(400px, calc(100vw - 2rem));
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: unset;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recents {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recents-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-row:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-remove:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script>
|
||||||
|
import { activeFilter, setFilter } from '$lib/stores/filters.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a single token from the active filter string.
|
||||||
|
* If it's the last token, clear the entire filter.
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
function removeToken(token) {
|
||||||
|
if (!$activeFilter) return;
|
||||||
|
const tokens = $activeFilter.trim().split(/\s+/);
|
||||||
|
const remaining = tokens.filter(t => t !== token);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
activeFilter.set(null);
|
||||||
|
} else {
|
||||||
|
setFilter(remaining.join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token */
|
||||||
|
function tokenType(token) {
|
||||||
|
if (token.startsWith('+')) return 'tag';
|
||||||
|
if (token.startsWith('-')) return 'exclude';
|
||||||
|
if (token.includes(':')) return 'attr';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$: tokens = $activeFilter ? $activeFilter.trim().split(/\s+/) : [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if tokens.length > 0}
|
||||||
|
<div class="filter-pills">
|
||||||
|
{#each tokens as token}
|
||||||
|
<button
|
||||||
|
class="filter-pill {tokenType(token)}"
|
||||||
|
type="button"
|
||||||
|
on:mousedown|preventDefault={() => removeToken(token)}
|
||||||
|
title="Remove filter: {token}"
|
||||||
|
>
|
||||||
|
<span class="pill-text">{token}</span>
|
||||||
|
<svg class="pill-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 1px;
|
||||||
|
align-items: stretch;
|
||||||
|
align-self: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: unset;
|
||||||
|
min-width: unset;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.tag {
|
||||||
|
background-color: var(--color-tag-bg);
|
||||||
|
color: var(--color-tag-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.exclude {
|
||||||
|
background-color: var(--color-priority-high-bg);
|
||||||
|
color: var(--color-priority-high-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.attr {
|
||||||
|
background-color: var(--color-project-bg);
|
||||||
|
color: var(--color-project-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.unknown {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-x {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill:hover .pill-x {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import ReportPicker from './ReportPicker.svelte';
|
import ReportPicker from './ReportPicker.svelte';
|
||||||
|
import FilterModal from './FilterModal.svelte';
|
||||||
|
import { activeFilter } from '$lib/stores/filters.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {string}
|
* @type {string}
|
||||||
@@ -14,6 +16,9 @@
|
|||||||
/** @type {ReportPicker} */
|
/** @type {ReportPicker} */
|
||||||
let picker;
|
let picker;
|
||||||
|
|
||||||
|
/** @type {FilterModal} */
|
||||||
|
let filterModal;
|
||||||
|
|
||||||
/** Map backend report names to display labels */
|
/** Map backend report names to display labels */
|
||||||
const reportLabels = /** @type {Record<string, string>} */ ({
|
const reportLabels = /** @type {Record<string, string>} */ ({
|
||||||
list: 'Pending',
|
list: 'Pending',
|
||||||
@@ -30,26 +35,43 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$: displayLabel = reportLabels[activeReport] || activeReport;
|
$: displayLabel = reportLabels[activeReport] || activeReport;
|
||||||
|
$: hasActiveFilter = !!$activeFilter;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<button
|
<div class="header-left">
|
||||||
class="report-btn"
|
<button
|
||||||
on:click={() => picker.toggle()}
|
class="report-btn"
|
||||||
>
|
on:click={() => picker.toggle()}
|
||||||
<span class="report-label">{displayLabel}</span>
|
>
|
||||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<span class="report-label">{displayLabel}</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="filter-btn"
|
||||||
|
class:active={hasActiveFilter}
|
||||||
|
on:click={() => filterModal.open()}
|
||||||
|
aria-label="Filter tasks"
|
||||||
|
>
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
{#if hasActiveFilter}
|
||||||
|
<span class="filter-dot"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -59,6 +81,11 @@
|
|||||||
onSelect={onReportChange}
|
onSelect={onReportChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FilterModal
|
||||||
|
bind:this={filterModal}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header {
|
.header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
@@ -67,7 +94,14 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-btn {
|
.report-btn {
|
||||||
@@ -100,6 +134,40 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: unset;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import PropertyPills from './PropertyPills.svelte';
|
import PropertyPills from './PropertyPills.svelte';
|
||||||
|
import FilterPills from './FilterPills.svelte';
|
||||||
|
import { activeFilter } from '$lib/stores/filters.js';
|
||||||
|
import { mergeInputWithFilter } from '$lib/utils/filters.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {(input: string) => Promise<void>}
|
* @type {(input: string) => Promise<void>}
|
||||||
@@ -21,8 +24,12 @@
|
|||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed || loading) return;
|
if (!trimmed || loading) return;
|
||||||
|
|
||||||
|
// Merge user input with active filter, deduplicating tokens
|
||||||
|
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(trimmed);
|
await onSubmit(merged);
|
||||||
value = '';
|
value = '';
|
||||||
} catch {
|
} catch {
|
||||||
// Value preserved for retry
|
// Value preserved for retry
|
||||||
@@ -78,16 +85,36 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current input value (for PropertyPills smart replace)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getInputValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the input value (for PropertyPills smart replace)
|
||||||
|
* @param {string} newValue
|
||||||
|
*/
|
||||||
|
export function setInputValue(newValue) {
|
||||||
|
value = newValue;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="input-bar">
|
<div class="input-bar">
|
||||||
<PropertyPills visible={focused} onInsert={insertAtCursor} />
|
<PropertyPills visible={focused} onInsert={insertAtCursor} inputValue={value} onInputChange={(v) => { value = v; }} />
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row" class:focused>
|
||||||
|
{#if $activeFilter}
|
||||||
|
<FilterPills />
|
||||||
|
<div class="separator"></div>
|
||||||
|
{/if}
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value
|
bind:value
|
||||||
@@ -95,7 +122,7 @@
|
|||||||
on:focus={handleFocus}
|
on:focus={handleFocus}
|
||||||
on:blur={handleBlur}
|
on:blur={handleBlur}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Add task... (e.g. Buy milk due:tomorrow priority:H)"
|
placeholder={$activeFilter ? "Add task..." : "Add task... (e.g. Buy milk due:tomorrow priority:H)"}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
@@ -121,25 +148,39 @@
|
|||||||
.input-bar {
|
.input-bar {
|
||||||
grid-area: input;
|
grid-area: input;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-row {
|
.input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
align-items: stretch;
|
||||||
align-items: center;
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row.focused {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border: 1px solid var(--border-color);
|
border: none;
|
||||||
border-radius: var(--border-radius);
|
border-radius: 0;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--bg-secondary);
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -150,8 +191,6 @@
|
|||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
@@ -163,7 +202,7 @@
|
|||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius);
|
border-radius: 0 calc(var(--border-radius) - 1px) calc(var(--border-radius) - 1px) 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
|
|||||||
@@ -1,21 +1,47 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { removeTokenByPrefix } from '$lib/utils/filters.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {(text: string) => void}
|
* @type {(text: string) => void}
|
||||||
*/
|
*/
|
||||||
export let onInsert;
|
export let onInsert;
|
||||||
|
|
||||||
|
/** Current input value for smart replace */
|
||||||
|
export let inputValue = '';
|
||||||
|
|
||||||
|
/** Callback to update the input value when doing smart replace */
|
||||||
|
export let onInputChange = /** @type {(value: string) => void} */ (() => {});
|
||||||
|
|
||||||
export let visible = false;
|
export let visible = false;
|
||||||
|
|
||||||
const pills = [
|
const pills = [
|
||||||
{ label: "Due", text: "due:" },
|
{ label: "Due", text: "due:", isTag: false },
|
||||||
{ label: "Pri", text: "priority:" },
|
{ label: "Pri", text: "priority:", isTag: false },
|
||||||
{ label: "Project", text: "project:" },
|
{ label: "Project", text: "project:", isTag: false },
|
||||||
{ label: "Tag", text: "+" },
|
{ label: "Tag", text: "+", isTag: true },
|
||||||
{ label: "Recur", text: "recur:" },
|
{ label: "Recur", text: "recur:", isTag: false },
|
||||||
{ label: "Scheduled", text: "scheduled:" },
|
{ label: "Scheduled", text: "scheduled:", isTag: false },
|
||||||
{ label: "Wait", text: "wait:" },
|
{ label: "Wait", text: "wait:", isTag: false },
|
||||||
{ label: "Until", text: "until:" },
|
{ label: "Until", text: "until:", isTag: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ text: string, isTag: boolean }} pill
|
||||||
|
*/
|
||||||
|
function handleInsert(pill) {
|
||||||
|
// Tags are always additive — no smart replace
|
||||||
|
if (!pill.isTag && inputValue) {
|
||||||
|
const prefix = pill.text; // e.g. "due:"
|
||||||
|
const cleaned = removeTokenByPrefix(inputValue, prefix);
|
||||||
|
if (cleaned !== inputValue) {
|
||||||
|
onInputChange(cleaned);
|
||||||
|
// Let the DOM update, then insert
|
||||||
|
requestAnimationFrame(() => onInsert(pill.text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onInsert(pill.text);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
@@ -24,7 +50,7 @@
|
|||||||
<button
|
<button
|
||||||
class="pill"
|
class="pill"
|
||||||
type="button"
|
type="button"
|
||||||
on:mousedown|preventDefault={() => onInsert(pill.text)}
|
on:mousedown|preventDefault={() => handleInsert(pill)}
|
||||||
>
|
>
|
||||||
{pill.label}
|
{pill.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { getItem, setItem } from '$lib/utils/storage.js';
|
||||||
|
|
||||||
|
const ACTIVE_KEY = 'opal_active_filter';
|
||||||
|
const RECENT_KEY = 'opal_recent_filters';
|
||||||
|
const MAX_RECENT = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a localStorage-backed writable store
|
||||||
|
* @template T
|
||||||
|
* @param {string} key
|
||||||
|
* @param {T} fallback
|
||||||
|
*/
|
||||||
|
function persisted(key, fallback) {
|
||||||
|
const initial = getItem(key) ?? fallback;
|
||||||
|
const store = writable(/** @type {T} */ (initial));
|
||||||
|
store.subscribe(value => setItem(key, value));
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
|
||||||
|
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active filter and add it to recents
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
export function setFilter(str) {
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
clearFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeFilter.set(trimmed);
|
||||||
|
|
||||||
|
recentFilters.update(recents => {
|
||||||
|
const deduped = recents.filter(r => r !== trimmed);
|
||||||
|
return [trimmed, ...deduped].slice(0, MAX_RECENT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the active filter
|
||||||
|
*/
|
||||||
|
export function clearFilter() {
|
||||||
|
activeFilter.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific entry from recents
|
||||||
|
* @param {string} str
|
||||||
|
*/
|
||||||
|
export function removeRecent(str) {
|
||||||
|
recentFilters.update(recents => recents.filter(r => r !== str));
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Valid filter attribute keys (these actually work as query filters in the engine)
|
||||||
|
*/
|
||||||
|
const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ParsedFilter
|
||||||
|
* @property {string[]} tags
|
||||||
|
* @property {string[]} excludeTags
|
||||||
|
* @property {Record<string, string>} attributes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a filter string into structured tokens.
|
||||||
|
* Recognizes +tag, -tag, and key:value for supported filter attributes.
|
||||||
|
* Unknown tokens (like due:3d) are preserved as raw tokens for pass-through.
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {ParsedFilter}
|
||||||
|
*/
|
||||||
|
export function parseFilterString(str) {
|
||||||
|
/** @type {ParsedFilter} */
|
||||||
|
const result = { tags: [], excludeTags: [], attributes: {} };
|
||||||
|
if (!str || !str.trim()) return result;
|
||||||
|
|
||||||
|
const tokens = str.trim().split(/\s+/);
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.startsWith('+') && token.length > 1) {
|
||||||
|
result.tags.push(token.slice(1));
|
||||||
|
} else if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
|
||||||
|
result.excludeTags.push(token.slice(1));
|
||||||
|
} else if (token.includes(':')) {
|
||||||
|
const idx = token.indexOf(':');
|
||||||
|
const key = token.slice(0, idx).toLowerCase();
|
||||||
|
const value = token.slice(idx + 1);
|
||||||
|
if (FILTER_ATTRS.has(key) && value) {
|
||||||
|
result.attributes[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a parsed filter to TaskFilters for the API
|
||||||
|
* @param {ParsedFilter} parsed
|
||||||
|
* @returns {import('$lib/api/types.js').TaskFilters}
|
||||||
|
*/
|
||||||
|
export function filterToParams(parsed) {
|
||||||
|
/** @type {import('$lib/api/types.js').TaskFilters} */
|
||||||
|
const params = {};
|
||||||
|
if (parsed.tags.length > 0) params.tags = parsed.tags;
|
||||||
|
if (parsed.excludeTags.length > 0) params.excludeTags = parsed.excludeTags;
|
||||||
|
if (parsed.attributes.status) params.status = /** @type {any} */ (parsed.attributes.status);
|
||||||
|
if (parsed.attributes.project) params.project = parsed.attributes.project;
|
||||||
|
if (parsed.attributes.priority) params.priority = parsed.attributes.priority;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a token matching a given prefix from a string.
|
||||||
|
* Used by PropertyPills smart replace: e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:")
|
||||||
|
* returns "buy milk"
|
||||||
|
* @param {string} input
|
||||||
|
* @param {string} prefix
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function removeTokenByPrefix(input, prefix) {
|
||||||
|
const tokens = input.split(/\s+/);
|
||||||
|
const filtered = tokens.filter(t => !t.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||||
|
return filtered.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate filter tokens from user input that are already in the active filter.
|
||||||
|
* Prevents submitting "+grocer +grocer" when filter is +grocer and user also typed +grocer.
|
||||||
|
* @param {string} userInput
|
||||||
|
* @param {string} filterStr
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function mergeInputWithFilter(userInput, filterStr) {
|
||||||
|
if (!filterStr || !filterStr.trim()) return userInput;
|
||||||
|
if (!userInput || !userInput.trim()) return filterStr;
|
||||||
|
|
||||||
|
const filterTokens = new Set(filterStr.trim().split(/\s+/).map(t => t.toLowerCase()));
|
||||||
|
const inputTokens = userInput.trim().split(/\s+/);
|
||||||
|
const deduped = inputTokens.filter(t => !filterTokens.has(t.toLowerCase()));
|
||||||
|
|
||||||
|
const combined = [...deduped, filterStr.trim()].filter(Boolean).join(' ');
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
|
grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: 1fr auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". header ."
|
|
||||||
". content ."
|
". content ."
|
||||||
". input .";
|
". input ."
|
||||||
|
". header .";
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { authStore } from '$lib/stores/auth.js';
|
import { authStore } from '$lib/stores/auth.js';
|
||||||
import { tasksStore } from '$lib/stores/tasks.js';
|
import { tasksStore } from '$lib/stores/tasks.js';
|
||||||
|
import { activeFilter, clearFilter } from '$lib/stores/filters.js';
|
||||||
|
import { parseFilterString, filterToParams } from '$lib/utils/filters.js';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import InputBar from '$lib/components/InputBar.svelte';
|
import InputBar from '$lib/components/InputBar.svelte';
|
||||||
@@ -24,11 +26,61 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadReport(activeReport);
|
// Load with existing active filter if any
|
||||||
|
if ($activeFilter) {
|
||||||
|
loadWithFilter($activeFilter);
|
||||||
|
} else {
|
||||||
|
loadReport(activeReport);
|
||||||
|
}
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React to filter changes
|
||||||
|
$: if ($activeFilter !== undefined) {
|
||||||
|
handleFilterChange($activeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string|null|undefined} */
|
||||||
|
let lastFilter = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null} filter
|
||||||
|
*/
|
||||||
|
function handleFilterChange(filter) {
|
||||||
|
// Skip initial (undefined -> initial value)
|
||||||
|
if (lastFilter === undefined) {
|
||||||
|
lastFilter = filter;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip if same value
|
||||||
|
if (filter === lastFilter) return;
|
||||||
|
lastFilter = filter;
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
loadWithFilter(filter);
|
||||||
|
} else {
|
||||||
|
loadReport(activeReport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filterStr
|
||||||
|
*/
|
||||||
|
async function loadWithFilter(filterStr) {
|
||||||
|
loading = true;
|
||||||
|
inputError = '';
|
||||||
|
try {
|
||||||
|
const parsed = parseFilterString(filterStr);
|
||||||
|
const params = filterToParams(parsed);
|
||||||
|
await tasksStore.load(params);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load filtered tasks:', error);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} reportName
|
* @param {string} reportName
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +101,10 @@
|
|||||||
*/
|
*/
|
||||||
function handleReportChange(reportName) {
|
function handleReportChange(reportName) {
|
||||||
activeReport = reportName;
|
activeReport = reportName;
|
||||||
|
// Changing report clears any active filter
|
||||||
|
if ($activeFilter) {
|
||||||
|
clearFilter();
|
||||||
|
}
|
||||||
loadReport(reportName);
|
loadReport(reportName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user