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') }}