From b4e0b69796792ccefe75be21c22a5e2fff7a4fcf Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sun, 26 Apr 2026 09:16:51 +0200 Subject: [PATCH] feat(web): mobile bottom navigation with More drawer Add an authenticated-only bottom bar below the md breakpoint with Heroicon-style tabs for Dashboard, Timer, Time entries, Projects, and More. More opens a slide-up sheet (backdrop, close, Escape) for Invoices, Clients, Reports, and user Settings, gated by module flags where applicable. Align shell layout to Tailwind md (768px): sidebar hidden md:flex, main md:ml-64 / md:ml-16 when collapsed, mobile hamburger md:hidden, RTL mainContent margin reset at 767px. Main column uses pb-16 on small screens so content clears the bar; bar and sheet use pb-safe (env safe-area) with a Tailwind safelist and @layer utilities rule. Remove the legacy six-slot FAB bottom nav from base.html. Docs: README UI overview, CHANGELOG [Unreleased], UI_GUIDELINES layout and file reference. --- CHANGELOG.md | 1 + README.md | 4 +- app/static/mobile.js | 92 ++++++++++++++++++++++++- app/static/src/input.css | 7 ++ app/templates/base.html | 54 ++++----------- app/templates/partials/_bottom_nav.html | 91 ++++++++++++++++++++++++ docs/UI_GUIDELINES.md | 3 + tailwind.config.js | 1 + 8 files changed, 211 insertions(+), 42 deletions(-) create mode 100644 app/templates/partials/_bottom_nav.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 93618c8b..68b96eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Desktop offline UI (bundle)** — Shared helpers load before dependent modules; timesheet period and time-off request lists expose **Delete** where allowed (with `currentUserProfile.id` for ownership); approve/reject controls read approval state from `state.currentUserProfile`; API client includes `deleteTimesheetPeriod` and `deleteTimeOffRequest`. ### Added +- **Mobile bottom navigation (web)** — On viewports below the `md` breakpoint (768px), signed-in users get a fixed bottom bar with tabs for Dashboard, Timer, Time entries, Projects, and **More**. **More** opens a slide-up drawer (backdrop, close control, Escape) linking to Invoices, Clients, Reports, and **My Settings** (`user.settings`), respecting module enablement where applicable. Implementation: [`app/templates/partials/_bottom_nav.html`](app/templates/partials/_bottom_nav.html) included from [`app/templates/base.html`](app/templates/base.html); [`app/static/mobile.js`](app/static/mobile.js) drives the drawer. **Safe area:** `pb-safe` utility in [`app/static/src/input.css`](app/static/src/input.css) and safelist in [`tailwind.config.js`](tailwind.config.js). Main content uses `pb-16` on small screens so it is not covered by the bar. Layout breakpoint for sidebar visibility, main margin, mobile menu, and RTL `#mainContent` margin is aligned to `md` (768px). - **Smart in-app notifications** — Opt-in under **Settings → Notifications → In-app reminders**: nudge when no time is logged today (configurable hour window, user timezone), alert when an active timer exceeds a configurable duration, and end-of-day summary of hours logged. Server-driven via `GET /api/notifications` and `POST /api/notifications/dismiss`; per-day dismissals stored in `user_smart_notification_dismissals`. Environment defaults: `SMART_NOTIFY_MAX_PER_DAY`, `SMART_NOTIFY_NO_TRACKING_AFTER`, `SMART_NOTIFY_SUMMARY_AT`, `SMART_NOTIFY_LONG_TIMER_HOURS`, `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` (see `app/config.py` and [docs/features/SMART_NOTIFICATIONS.md](docs/features/SMART_NOTIFICATIONS.md)). Migration `150_add_smart_notifications`. The dashboard client polls the API and shows toasts (optional browser notifications when enabled and permission granted). `toastManager.show` supports an optional `onDismiss` callback. - **Value dashboard widget** — Dashboard productivity block backed by `StatsService` and `GET /api/stats/value-dashboard` (short-TTL Redis cache when available). Wired from `dashboard-enhancements.js` with the existing real-time dashboard refresh. - **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** forms include per-row **Move up** / **Move down** controls on **Quote line items**, **Costs**, and **Extra goods** so rows can be reordered without deleting and re-entering data; PDFs and detail views follow the saved order. New translatable UI strings: **Order**, **Move up**, **Move down** (run `pybabel extract` / `update` per [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md)). diff --git a/README.md b/README.md index 2b309f03..7f7f2227 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ TimeTracker is built with modern, reliable technologies: ## 🖥️ UI overview -The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports** (time, project, finance) are available from the sidebar (top-level **Reports** link or **Finance & Expenses → Reports** for Report Builder, Saved Views, Scheduled Reports), and from the bottom bar on mobile. **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md). +The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports** (time, project, finance) are available from the sidebar (top-level **Reports** link or **Finance & Expenses → Reports** for Report Builder, Saved Views, Scheduled Reports). **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. + +On **narrow viewports** (below the `md` breakpoint), the sidebar is hidden in favor of a **fixed bottom navigation bar** (inline Heroicons): Dashboard, Timer, Time entries, Projects, and **More** (slide-up sheet for Invoices, Clients, Reports, and user Settings when those modules or routes apply). The hamburger control still opens the full sidebar overlay if you need every menu item. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md). --- diff --git a/app/static/mobile.js b/app/static/mobile.js index a4f03a73..9be7b794 100644 --- a/app/static/mobile.js +++ b/app/static/mobile.js @@ -3,7 +3,7 @@ const MobileUtils = { TOUCH_TARGET_MIN: 44, - MOBILE_BREAKPOINT: 1024, + MOBILE_BREAKPOINT: 768, SMALL_MOBILE_BREAKPOINT: 480, isMobile() { @@ -206,6 +206,95 @@ class MobilePerformance { } } +/** Slide-up "More" drawer for mobile bottom navigation (see partials/_bottom_nav.html). */ +class BottomNavMoreDrawer { + constructor() { + this.btn = document.getElementById('bottomNavMoreBtn'); + this.backdrop = document.getElementById('bottomNavMoreBackdrop'); + this.panel = document.getElementById('bottomNavMorePanel'); + this.closeBtn = document.getElementById('bottomNavMoreClose'); + if (!this.btn || !this.backdrop || !this.panel) return; + this._onDocKeydown = this._onDocKeydown.bind(this); + this.init(); + } + + isOpen() { + return !this.backdrop.classList.contains('hidden'); + } + + open() { + if (!MobileUtils.isMobile()) return; + this.backdrop.classList.remove('hidden'); + requestAnimationFrame(() => { + this.panel.classList.remove('pointer-events-none', 'translate-y-full'); + this.panel.setAttribute('aria-hidden', 'false'); + this.btn.setAttribute('aria-expanded', 'true'); + document.body.classList.add('overflow-hidden'); + }); + } + + close() { + this.panel.classList.add('translate-y-full', 'pointer-events-none'); + this.panel.setAttribute('aria-hidden', 'true'); + this.btn.setAttribute('aria-expanded', 'false'); + document.body.classList.remove('overflow-hidden'); + + const hideBackdrop = () => { + this.backdrop.classList.add('hidden'); + }; + + const reduced = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (reduced) { + hideBackdrop(); + return; + } + + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + hideBackdrop(); + }; + + const onEnd = (e) => { + if (e.target !== this.panel || e.propertyName !== 'transform') return; + this.panel.removeEventListener('transitionend', onEnd); + clearTimeout(fallbackTimer); + finish(); + }; + this.panel.addEventListener('transitionend', onEnd); + const fallbackTimer = window.setTimeout(() => { + this.panel.removeEventListener('transitionend', onEnd); + finish(); + }, 400); + } + + _onDocKeydown(e) { + if (e.key === 'Escape' && this.isOpen()) { + this.close(); + } + } + + init() { + this.btn.addEventListener('click', (e) => { + e.preventDefault(); + if (this.isOpen()) this.close(); + else this.open(); + }); + this.backdrop.addEventListener('click', () => this.close()); + if (this.closeBtn) { + this.closeBtn.addEventListener('click', () => this.close()); + } + document.addEventListener('keydown', this._onDocKeydown); + this.panel.querySelectorAll('a.bottom-nav-more-link').forEach((a) => { + a.addEventListener('click', () => this.close()); + }); + } +} + class MobileOffline { constructor() { this.offlineToastId = null; @@ -241,6 +330,7 @@ document.addEventListener('DOMContentLoaded', function() { new MobileViewport(); new MobilePerformance(); new MobileOffline(); + new BottomNavMoreDrawer(); }); window.MobileUtils = MobileUtils; diff --git a/app/static/src/input.css b/app/static/src/input.css index 3d72ec3f..5efe187c 100644 --- a/app/static/src/input.css +++ b/app/static/src/input.css @@ -637,6 +637,13 @@ } } +@layer utilities { + /* iOS home indicator / safe area (used by mobile bottom nav; safelist in tailwind.config.js) */ + .pb-safe { + padding-bottom: env(safe-area-inset-bottom, 0px); + } +} + /* Focus, skip link, responsive, reduced motion, high contrast (from enhanced-ui / ui-enhancements) */ :focus-visible { outline: 2px solid #4A90E2; outline-offset: 2px; } .skip-link { position: absolute; top: -40px; left: 0; background: #4A90E2; color: white; padding: 8px 16px; z-index: 100; border-radius: 0 0 4px 0; } diff --git a/app/templates/base.html b/app/templates/base.html index 570f6d17..541bef0c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -65,7 +65,7 @@ html[dir="rtl"] .text-right { text-align: left; } html[dir="rtl"] #sidebar { left: auto; right: 0; } html[dir="rtl"] #mainContent { margin-left: 0; margin-right: 16rem; } - @media (max-width: 1024px) { + @media (max-width: 767px) { html[dir="rtl"] #mainContent { margin-right: 0; } } @@ -198,7 +198,8 @@ white-space: nowrap; border-width: 0; } - .mobile-bottom-nav a.relative { + .mobile-bottom-nav a.relative, + .mobile-bottom-nav button.relative { padding-top: 0.25rem; padding-bottom: 0.25rem; } @@ -361,7 +362,7 @@ {{ _('Skip to content') }}
-