Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/Domain/Tickets/Models/BoardSummary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Leantime\Domain\Tickets\Models;

use Carbon\CarbonImmutable;

/**
* At-a-glance metrics for a task board, computed from the tickets currently on
* screen (so the numbers always reflect the active filters). Rendered in the
* page-header sub-line.
*/
class BoardSummary
{
/** Total tasks on the board. */
public int $total = 0;

/** Tasks with no assignee (editorId). */
public int $unassigned = 0;

/** Tasks whose due date falls between today and the end of the current week. */
public int $dueThisWeek = 0;

/** Most recent change on the board (max ticket modified date), null if none. */
public ?CarbonImmutable $lastUpdated = null;
}
56 changes: 55 additions & 1 deletion app/Domain/Tickets/Services/Tickets.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use Leantime\Domain\Tickets\Events\TicketListFilter;
use Leantime\Domain\Tickets\Events\TicketUpdated;
use Leantime\Domain\Tickets\Events\TodoWidgetTasksFilter;
use Leantime\Domain\Tickets\Models\BoardSummary;
use Leantime\Domain\Tickets\Models\Tickets as TicketModel;
use Leantime\Domain\Tickets\Permissions\TicketsPermissions;
use Leantime\Domain\Tickets\Repositories\TicketHistory;
Expand Down Expand Up @@ -3966,8 +3967,60 @@ public function getNewFieldOptions(): array
}

/**
* @throws BindingResolutionException
* Compute at-a-glance board metrics (total, unassigned, due-this-week, last
* updated) from an already-fetched grouped ticket set. Working off the rows
* the board just rendered means the counts reflect the current filters and
* cost no extra query.
*
* @param array $groupedTickets Grouped tickets as returned by getAllGrouped()
* (each group holds its rows under 'items').
*
* @api
*/
public function getBoardSummary(array $groupedTickets): BoardSummary
{
Comment on lines +3975 to +3981
$summary = new BoardSummary;

// "This week" = today through the end of the current week, compared as
// calendar dates in the user's timezone (Y-m-d string compare is
// chronological) so a date-only due date lands in the intended week.
$userNow = dtHelper()->userNow();
$weekStartDate = $userNow->format('Y-m-d');
$weekEndDate = $userNow->endOfWeek()->format('Y-m-d');

foreach ($groupedTickets as $group) {
foreach ($group['items'] ?? [] as $ticket) {
$summary->total++;

$editorId = is_object($ticket) ? ($ticket->editorId ?? null) : ($ticket['editorId'] ?? null);
if (empty($editorId)) {
$summary->unassigned++;
}

$due = is_object($ticket) ? ($ticket->dateToFinish ?? null) : ($ticket['dateToFinish'] ?? null);
// isValidDateString() is exactly what parseDbDateTime() guards on
// (rejects empty, 0000-00-00, and the 1969-12-31 sentinel), so this
// can never throw on a sentinel/invalid value.
if (dtHelper()->isValidDateString($due !== null ? (string) $due : null)) {
$dueDate = dtHelper()->parseDbDateTime((string) $due)->setToUserTimezone()->format('Y-m-d');
if ($dueDate >= $weekStartDate && $dueDate <= $weekEndDate) {
$summary->dueThisWeek++;
}
}
Comment on lines +4016 to +4023
Comment on lines +4016 to +4023

$modified = is_object($ticket) ? ($ticket->modified ?? null) : ($ticket['modified'] ?? null);
if (dtHelper()->isValidDateString($modified !== null ? (string) $modified : null)) {
$modifiedDate = dtHelper()->parseDbDateTime((string) $modified);
if ($summary->lastUpdated === null || $modifiedDate->greaterThan($summary->lastUpdated)) {
$summary->lastUpdated = $modifiedDate;
}
}
Comment on lines +4025 to +4031
Comment on lines +4025 to +4031
}
}

return $summary;
}

public function getTicketTemplateAssignments($params): array
{

Expand Down Expand Up @@ -4019,6 +4072,7 @@ public function getTicketTemplateAssignments($params): array
'currentSprint' => session('currentSprint'),
'searchCriteria' => $searchCriteria,
'allTickets' => $allTickets,
'boardSummary' => $this->getBoardSummary($allTickets),
'allTicketStates' => $allTicketStates,
'efforts' => $efforts,
'priorities' => $priorities,
Expand Down
14 changes: 4 additions & 10 deletions app/Domain/Tickets/Templates/showAll.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,11 @@

<div class="maincontentinner">

{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs). Only the table-specific DataTables buttons remain
here, right-aligned above the table. --}}
<div class="row">
<div class="col-md-4">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>

<div class="col-md-4 center">
</div>
<div class="col-md-4">
<div class="col-md-12">
<div class="pull-right">
@dispatchEvent('filters.afterRighthandSectionOpen')
<div id="tableButtons" style="display:inline-block"></div>
Expand Down
16 changes: 2 additions & 14 deletions app/Domain/Tickets/Templates/showKanban.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,8 @@

<div class="maincontentinner kanban-board-wrapper" >

<div class="row">
<div class="col-md-4">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>

<div class="col-md-4 center">
</div>
<div class="col-md-4">
</div>
</div>

{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs) so there's no separate toolbar row here. --}}
<div class="clearfix"></div>

@if ($programBoard)
Expand Down
16 changes: 2 additions & 14 deletions app/Domain/Tickets/Templates/showList.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,8 @@

<div class="maincontentinner">

<div class="row">
<div class="col-md-4">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>

<div class="col-md-4 center">
</div>
<div class="col-md-4">
</div>
</div>

{{-- Board actions (New / Filter / Group By) moved into the nav bar
(ticketBoardTabs) so there's no separate toolbar row here. --}}
<div class="clearfix"></div>

@dispatchEvent('allTicketsTable.before', ['tickets' => $allTickets])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,17 @@ function findActive($route): string
</a>
</li>
</ul>

{{-- Board actions (New / Filter / Group By) live on the right of the nav bar
— like the report's period picker — so the bar is balanced and the board
needs no separate toolbar row below it. Guarded on $searchCriteria so the
partial stays safe if the nav is ever reused without the board context. --}}
@isset($searchCriteria)
<div class="tabs-actions">
@dispatchEvent('filters.afterLefthandSectionOpen')
@include('tickets::submodules.ticketNewBtn')
@include('tickets::submodules.ticketFilter')
@dispatchEvent('filters.beforeLefthandSectionClose')
</div>
@endisset
</div>
78 changes: 45 additions & 33 deletions app/Domain/Tickets/Templates/submodules/ticketHeader.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@
<div class="pagetitle">
<h5>{{ session('currentProjectClient') ?? '' . ' // ' . session('currentProjectName') ?? '' }}</h5>

{{-- Migrated to the shared subject switcher (was a hand-rolled
header-title-dropdown). The sprint menu items stay here — they're
domain-specific — but the "To-Dos // <current> ▾" chrome is now the
component. Zero visual change. --}}
<x-global::subjectSwitcher
:parent="__('headlines.todos')"
:current="$sprint !== false ? $sprint->name : __('label.select_sprint')">
<li><a class="wikiModal inlineEdit" href="#/sprints/editSprint/"><i class="fa-solid fa-plus"></i> {!! __('links.create_sprint_no_icon') !!}</a></li>
<li class='nav-header border'></li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('all'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.all_todos') !!}</a>
</li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('backlog'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.backlog') !!}</a>
</li>
@foreach ($sprints as $sprintRow)
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val({{ $sprintRow->id }}); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{{ $tpl->escape($sprintRow->name) }}@if (! empty($sprintRow->isInherited)) <span class="label label-info">{{ __('label.program_sprint') }}</span>@endif<br /><small>{!! sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()) !!}</small></a>
</li>
@endforeach
</x-global::subjectSwitcher>
<input type="hidden" name="sprintSelect" id="sprintSelect" value="{{ $currentSprintId }}" />
</div>

{{-- Right cluster on the breadcrumb bar: board stats + (for a real sprint
view only) the sprint edit/delete ⋮ menu. --}}
<div class="pageheader-right">
@isset($boardSummary)
@php
// Board metrics as a one-line meta string; segments join with a
// single " · " so it reads cleanly no matter which are present.
$summaryParts = [sprintf(__('label.board_task_count'), $boardSummary->total)];
if ($boardSummary->unassigned > 0) {
$summaryParts[] = sprintf(__('label.board_unassigned'), $boardSummary->unassigned);
}
if ($boardSummary->dueThisWeek > 0) {
$summaryParts[] = sprintf(__('label.board_due_this_week'), $boardSummary->dueThisWeek);
}
if ($boardSummary->lastUpdated !== null) {
$summaryParts[] = sprintf(__('label.board_updated'), $boardSummary->lastUpdated->setToUserTimezone()->diffForHumans());
}
@endphp
<div class="pageheader-meta">{{ implode(' · ', $summaryParts) }}</div>
@endisset

@if (
($currentSprint !== false)
&& ($currentSprint !== null)
Expand All @@ -61,39 +106,6 @@
</ul>
</span>
@endif

<h1>
{!! __('headlines.todos') !!}
//
<span class="dropdown dropdownWrapper">
<a href="javascript:void(0)" class="dropdown-toggle header-title-dropdown" data-toggle="dropdown">
@if ($sprint !== false)
{{ $sprint->name }}
@else
{!! __('label.select_sprint') !!}
@endif
<i class="fa fa-caret-down"></i>
</a>

<ul class="dropdown-menu">
<li><a class="wikiModal inlineEdit" href="#/sprints/editSprint/"><i class="fa-solid fa-plus"></i> {!! __('links.create_sprint_no_icon') !!}</a></li>
<li class='nav-header border'></li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('all'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.all_todos') !!}</a>
</li>
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val('backlog'); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{!! __('links.backlog') !!}</a>
</li>
@foreach ($sprints as $sprintRow)
<li>
<a href="javascript:void(0);" onclick="jQuery('#sprintSelect').val({{ $sprintRow->id }}); leantime.ticketsController.initTicketSearchUrlBuilder('{{ $currentUrlPath }}')">{{ $tpl->escape($sprintRow->name) }}@if (! empty($sprintRow->isInherited)) <span class="label label-info">{{ __('label.program_sprint') }}</span>@endif<br /><small>{!! sprintf(__('label.date_from_date_to'), format($sprintRow->startDate)->date(), format($sprintRow->endDate)->date()) !!}</small></a>
</li>
@endforeach
</ul>
</span>

</h1>
<input type="hidden" name="sprintSelect" id="sprintSelect" value="{{ $currentSprintId }}" />
</div>
@dispatchEvent('beforePageHeaderClose')
</div><!--pageheader-->
Expand Down
4 changes: 4 additions & 0 deletions app/Language/en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,10 @@ label.testing = "Review/Testing"
label.date = "Date"
label.hours = "Hours"
label.backlog = "Backlog"
label.board_task_count = "%d tasks"
label.board_unassigned = "%d unassigned"
label.board_due_this_week = "%d due this week"
label.board_updated = "updated %s"
label.date_from_date_to = "%1$s - %2$s"
label.user = "User"
label.all_milestones = "All Milestones"
Expand Down
71 changes: 71 additions & 0 deletions app/Views/Templates/components/subjectSwitcher.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{{--
Subject switcher — the "Parent // Current ▾" page-title dropdown.

Consolidates the `header-title-dropdown` pattern that was hand-rolled inline
across ~18 templates (To-Dos sprint switcher, canvas boards, wiki, ideas,
goals, projects…). One place, one markup, reusable.

Renders an <h1> with an optional parent crumb, a separator, and a Bootstrap
dropdown whose toggle shows the current subject. The MENU ITEMS are the
slot — each consumer supplies its own <li> options (they carry their own
href/onclick), so the switch behavior stays domain-specific while the
chrome is shared.

Keeps the existing classes (.header-title-dropdown, .dropdown,
.dropdown-menu) so the established CSS (dropdowns.css) and Bootstrap
data-toggle behavior apply unchanged — migrating a consumer is a
zero-visual-change swap.

Props:
parent string|null Parent crumb label (e.g. "To-Dos"). Escaped on
output — pass plain text (callers pass __()).
parentHref string|null Optional link for the parent crumb.
current string The current subject name (escaped — user data safe).
separator string House-style divider. Default "›" (breadcrumb chevron).
switchStyle 'legacy'|'pill' Visual variant. 'legacy' = the established
underlined-caret look. 'pill' is reserved for the
modern treatment (styled in a follow-up); the prop
exists now so it's a one-line flip later.

Slot: the <li> dropdown-menu items.
--}}
@props([
'parent' => null,
'parentHref' => null,
'current' => '',
'separator' => '›',
'switchStyle' => 'legacy',
])

@once
<style>
/* Breadcrumb parent — subtle gray, no underline (was a heavy link). */
.pagetitle .subjectSwitcher-parent{color:var(--primary-font-color);opacity:.55;text-decoration:none;font-weight:500;}
.pagetitle .subjectSwitcher-parent:hover{opacity:.9;text-decoration:underline;}
.pagetitle .subjectSwitcher-sep{color:var(--primary-font-color);opacity:.35;margin:0 2px;}
</style>
@endonce
<h1 @class(['subjectSwitcher', 'subjectSwitcher--pill' => $switchStyle === 'pill'])>
@if (! empty($parent))
@if (! empty($parentHref))
<a href="{{ $parentHref }}" class="subjectSwitcher-parent">{{ $parent }}</a>
@else
{{ $parent }}
@endif
Comment on lines +50 to +54
<span class="subjectSwitcher-sep" aria-hidden="true">{{ $separator }}</span>
@endif
Comment thread
Copilot marked this conversation as resolved.
<span class="dropdown dropdownWrapper">
<a href="javascript:void(0)" class="dropdown-toggle header-title-dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ $current }}
<i class="fa fa-caret-down"></i>
</a>
<ul class="dropdown-menu">
{{ $slot }}
</ul>
</span>
</h1>
@isset($subline)
{{-- Optional light meta/metrics line under the title (e.g. "Strategy report ·
updated Jul 20", or on Tasks "24 tickets · last updated 5m ago"). --}}
<div class="subjectSwitcher-subline">{{ $subline }}</div>
@endisset
19 changes: 19 additions & 0 deletions app/Views/Templates/sections/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,26 @@ functions that run on/after DOMContentLoaded (e.g. initCalendar()), never at
@dispatchEvent('afterScriptsAndStyles')

<!-- Replace main theme colors -->
@php
// The nav bar sets white controls on the accent gradient. A light accent is
// a mid-tone that fails WCAG AA for white text; detect that per-theme and
// enable a dark scrim only then, so dark-accent themes stay fully vivid.
$navNeedsScrim = false;
foreach ($accents as $accentColor) {
if (! is_string($accentColor) || ! preg_match('/^#?([0-9a-fA-F]{6})$/', $accentColor, $accentHex)) {
continue;
}
[$ar, $ag, $ab] = sscanf($accentHex[1], '%02x%02x%02x');
$linChannel = static fn ($v) => ($v /= 255) <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
$accentLum = 0.2126 * $linChannel($ar) + 0.7152 * $linChannel($ag) + 0.0722 * $linChannel($ab);
if (1.05 / ($accentLum + 0.05) < 4.5) { // white-on-accent below AA
$navNeedsScrim = true;
break;
}
}
@endphp
<style id="colorSchemeSetter">
:root { --nav-scrim: {{ $navNeedsScrim ? '0.22' : '0' }}; }
@foreach ($accents as $accent)
@if($accent !== false)
:root {
Expand Down
Loading
Loading