feat(tasks): persist view mode and support ?view=kanban URL parameter

View mode (list/kanban) is now saved to localStorage and restored on
page load. URL parameter ?view=kanban takes precedence, enabling tablet
kiosk setups to default to Kanban view. Toggle buttons reflect the
active view correctly on initial render.

Closes #17
This commit is contained in:
Ulas
2026-04-04 22:34:29 +02:00
parent 2c36fa0307
commit 0421b540cd
4 changed files with 26 additions and 9 deletions
+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.9.1] - 2026-04-04
### Added
- Persist task view mode (list/kanban) across sessions via localStorage (#17)
- Support URL parameter `?view=kanban` to open tasks directly in Kanban view - ideal for tablet kiosk setups
- View toggle button reflects the persisted/URL-driven view on page load
## [0.9.0] - 2026-04-04 ## [0.9.0] - 2026-04-04
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.9.0", "version": "0.9.1",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+15 -5
View File
@@ -356,7 +356,7 @@ let state = {
users: [], users: [],
filters: { status: '', priority: '', assigned_to: '' }, filters: { status: '', priority: '', assigned_to: '' },
groupMode: 'category', // 'category' | 'due' groupMode: 'category', // 'category' | 'due'
viewMode: 'list', // 'list' | 'kanban' viewMode: 'list', // 'list' | 'kanban' (resolved at render time)
expandedTasks: new Set(), expandedTasks: new Set(),
dragTaskId: null, dragTaskId: null,
}; };
@@ -872,6 +872,7 @@ function wireViewToggle(container) {
toggle.querySelectorAll('[data-view]').forEach((btn) => { toggle.querySelectorAll('[data-view]').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
state.viewMode = btn.dataset.view; state.viewMode = btn.dataset.view;
localStorage.setItem('oikos-tasks-view', state.viewMode);
toggle.querySelectorAll('[data-view]').forEach((b) => toggle.querySelectorAll('[data-view]').forEach((b) =>
b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode) b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode)
); );
@@ -962,23 +963,32 @@ function wireTaskList(container) {
// -------------------------------------------------------- // --------------------------------------------------------
export async function render(container, { user }) { export async function render(container, { user }) {
// Initiales Skeleton // View-Mode: URL-Parameter > localStorage > Default 'list'
const urlView = new URLSearchParams(window.location.search).get('view');
const savedView = localStorage.getItem('oikos-tasks-view');
state.viewMode = (urlView === 'kanban' || urlView === 'list') ? urlView
: (savedView === 'kanban' || savedView === 'list') ? savedView
: 'list';
const isKanban = state.viewMode === 'kanban';
// Initiales Skeleton (all values are from i18n keys or hardcoded constants, no user data)
container.innerHTML = ` container.innerHTML = `
<div class="tasks-page"> <div class="tasks-page">
<div class="tasks-toolbar"> <div class="tasks-toolbar">
<h1 class="tasks-toolbar__title">${t('tasks.title')}</h1> <h1 class="tasks-toolbar__title">${t('tasks.title')}</h1>
<div class="tasks-toolbar__actions"> <div class="tasks-toolbar__actions">
<div class="group-toggle" id="view-toggle"> <div class="group-toggle" id="view-toggle">
<button class="group-toggle__btn group-toggle__btn--active" data-view="list" <button class="group-toggle__btn ${isKanban ? '' : 'group-toggle__btn--active'}" data-view="list"
title="${t('tasks.listView')}" aria-label="${t('tasks.listView')}"> title="${t('tasks.listView')}" aria-label="${t('tasks.listView')}">
<i data-lucide="list" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i> <i data-lucide="list" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
</button> </button>
<button class="group-toggle__btn" data-view="kanban" <button class="group-toggle__btn ${isKanban ? 'group-toggle__btn--active' : ''}" data-view="kanban"
title="${t('tasks.kanbanView')}" aria-label="${t('tasks.kanbanView')}"> title="${t('tasks.kanbanView')}" aria-label="${t('tasks.kanbanView')}">
<i data-lucide="columns" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i> <i data-lucide="columns" style="width:14px;height:14px;pointer-events:none" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="group-toggle" id="group-mode-toggle"> <div class="group-toggle" id="group-mode-toggle" ${isKanban ? 'style="display:none"' : ''}>
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">${t('tasks.categoryLabel')}</button> <button class="group-toggle__btn group-toggle__btn--active" data-mode="category">${t('tasks.categoryLabel')}</button>
<button class="group-toggle__btn" data-mode="due">${t('tasks.dueDateLabel')}</button> <button class="group-toggle__btn" data-mode="due">${t('tasks.dueDateLabel')}</button>
</div> </div>
+3 -3
View File
@@ -12,9 +12,9 @@
* API: Immer Netzwerk (kein Caching von Nutzerdaten) * API: Immer Netzwerk (kein Caching von Nutzerdaten)
*/ */
const SHELL_CACHE = 'oikos-shell-v24'; const SHELL_CACHE = 'oikos-shell-v25';
const PAGES_CACHE = 'oikos-pages-v24'; const PAGES_CACHE = 'oikos-pages-v25';
const ASSETS_CACHE = 'oikos-assets-v24'; const ASSETS_CACHE = 'oikos-assets-v25';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
// App-Shell: sofort benötigt für ersten Render // App-Shell: sofort benötigt für ersten Render