ui updates

This commit is contained in:
2026-02-18 23:16:00 +01:00
parent f05d6e154e
commit a551f50cef
13 changed files with 800 additions and 43 deletions
+4
View File
@@ -0,0 +1,4 @@
# Wait/Scheduled not working correctly
`Buy milk due:8d wait:5d` still showing up
# Missing uncomplete feat
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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="theme-color" content="#4f46e5" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+3
View File
@@ -25,6 +25,9 @@ export const tasks = {
if (filters.tags) {
filters.tags.forEach(tag => params.append('tag', tag));
}
if (filters.excludeTags) {
filters.excludeTags.forEach(tag => params.append('exclude_tag', tag));
}
const query = params.toString();
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
+1
View File
@@ -77,6 +77,7 @@
* @property {string} [project]
* @property {string} [priority]
* @property {string[]} [tags]
* @property {string[]} [excludeTags]
*/
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>
+84 -16
View File
@@ -1,5 +1,7 @@
<script>
import ReportPicker from './ReportPicker.svelte';
import FilterModal from './FilterModal.svelte';
import { activeFilter } from '$lib/stores/filters.js';
/**
* @type {string}
@@ -14,6 +16,9 @@
/** @type {ReportPicker} */
let picker;
/** @type {FilterModal} */
let filterModal;
/** Map backend report names to display labels */
const reportLabels = /** @type {Record<string, string>} */ ({
list: 'Pending',
@@ -30,26 +35,43 @@
});
$: displayLabel = reportLabels[activeReport] || activeReport;
$: hasActiveFilter = !!$activeFilter;
</script>
<header class="header">
<button
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="header-left">
<button
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</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">
<a href="/settings" class="settings-btn" aria-label="Settings">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
<a href="/settings" class="settings-btn" aria-label="Settings">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
</div>
</header>
@@ -59,6 +81,11 @@
onSelect={onReportChange}
/>
<FilterModal
bind:this={filterModal}
onClose={() => {}}
/>
<style>
.header {
grid-area: header;
@@ -67,7 +94,14 @@
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
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 {
@@ -100,6 +134,40 @@
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 {
display: flex;
align-items: center;
+52 -13
View File
@@ -1,5 +1,8 @@
<script>
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>}
@@ -21,8 +24,12 @@
async function handleSubmit() {
const trimmed = value.trim();
if (!trimmed || loading) return;
// Merge user input with active filter, deduplicating tokens
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
try {
await onSubmit(trimmed);
await onSubmit(merged);
value = '';
} catch {
// 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>
<div class="input-bar">
<PropertyPills visible={focused} onInsert={insertAtCursor} />
<PropertyPills visible={focused} onInsert={insertAtCursor} inputValue={value} onInputChange={(v) => { value = v; }} />
{#if error}
<div class="error">{error}</div>
{/if}
<div class="input-row">
<div class="input-row" class:focused>
{#if $activeFilter}
<FilterPills />
<div class="separator"></div>
{/if}
<input
bind:this={inputEl}
bind:value
@@ -95,7 +122,7 @@
on:focus={handleFocus}
on:blur={handleBlur}
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}
class="input"
/>
@@ -121,25 +148,39 @@
.input-bar {
grid-area: input;
padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
background-color: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
.input-row {
display: flex;
gap: var(--spacing-sm);
align-items: center;
align-items: stretch;
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 {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
border: none;
border-radius: 0;
font-size: var(--font-size-base);
font-family: inherit;
background-color: var(--bg-secondary);
background: transparent;
color: var(--text-primary);
min-width: 0;
}
@@ -150,8 +191,6 @@
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--focus-ring);
}
.submit-btn {
@@ -163,7 +202,7 @@
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--border-radius);
border-radius: 0 calc(var(--border-radius) - 1px) calc(var(--border-radius) - 1px) 0;
cursor: pointer;
flex-shrink: 0;
min-width: 44px;
@@ -1,21 +1,47 @@
<script>
import { removeTokenByPrefix } from '$lib/utils/filters.js';
/**
* @type {(text: string) => void}
*/
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;
const pills = [
{ label: "Due", text: "due:" },
{ label: "Pri", text: "priority:" },
{ label: "Project", text: "project:" },
{ label: "Tag", text: "+" },
{ label: "Recur", text: "recur:" },
{ label: "Scheduled", text: "scheduled:" },
{ label: "Wait", text: "wait:" },
{ label: "Until", text: "until:" },
{ label: "Due", text: "due:", isTag: false },
{ label: "Pri", text: "priority:", isTag: false },
{ label: "Project", text: "project:", isTag: false },
{ label: "Tag", text: "+", isTag: true },
{ label: "Recur", text: "recur:", isTag: false },
{ label: "Scheduled", text: "scheduled:", isTag: false },
{ label: "Wait", text: "wait:", isTag: false },
{ 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>
{#if visible}
@@ -24,7 +50,7 @@
<button
class="pill"
type="button"
on:mousedown|preventDefault={() => onInsert(pill.text)}
on:mousedown|preventDefault={() => handleInsert(pill)}
>
{pill.label}
</button>
+55
View File
@@ -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));
}
+90
View File
@@ -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;
}
+3 -3
View File
@@ -29,11 +29,11 @@
height: 100dvh;
display: grid;
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:
". header ."
". content ."
". input .";
". input ."
". header .";
overflow: hidden;
background: linear-gradient(
to right,
+57 -1
View File
@@ -3,6 +3,8 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.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 TaskList from '$lib/components/TaskList.svelte';
import InputBar from '$lib/components/InputBar.svelte';
@@ -24,11 +26,61 @@
return;
}
loadReport(activeReport);
// Load with existing active filter if any
if ($activeFilter) {
loadWithFilter($activeFilter);
} else {
loadReport(activeReport);
}
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
*/
@@ -49,6 +101,10 @@
*/
function handleReportChange(reportName) {
activeReport = reportName;
// Changing report clears any active filter
if ($activeFilter) {
clearFilter();
}
loadReport(reportName);
}