diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d589c..9124d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-22](https://github.com/itk-dev/itk-project-database/pull/22) + Use the ITK logo in the nav and login, tidy the dashboard header, and refresh + the login screen. * [PR-19](https://github.com/itk-dev/itk-project-database/pull/19) Auto-upload files with a progress bar and image preview, view images in an in-page lightbox, and refresh the media field styling. +* [PR-18](https://github.com/itk-dev/itk-project-database/pull/18) + Rework the dashboard with an outstanding-work panel and a redesigned activity + feed, add help text to every graph, and fix the mascot's finish nudges. * [PR-17](https://github.com/itk-dev/itk-project-database/pull/17) Fix the mascot nudges that never appeared, nudge users to finish incomplete contacts with a link to their edit page. diff --git a/assets/controllers/dashboard_viz_controller.js b/assets/controllers/dashboard_viz_controller.js index 9c86ec1..bd24e8e 100644 --- a/assets/controllers/dashboard_viz_controller.js +++ b/assets/controllers/dashboard_viz_controller.js @@ -65,6 +65,10 @@ export default class extends Controller { "timeline", ]; + static values = { + empty: String, + }; + connect() { this.reduce = window.matchMedia( "(prefers-reduced-motion: reduce)", @@ -221,6 +225,18 @@ export default class extends Controller { buildHeatmap() { const d = this.viz; const el = this.heatmapTarget; + + // The heatmap only makes sense once there are both rows (departments) and + // columns (areas) to cross; until then show a placeholder, not bare headers. + if (!d.areas.length || !d.departments.length) { + el.style.gridTemplateColumns = ""; + el.classList.remove("heat--paused"); + el.innerHTML = `
${this.esc(this.emptyValue)}
`; + this.heatColHeads = []; + this.heatCells = []; + return; + } + el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.areas.length}, minmax(40px, 1fr))`; if (!this.reduce) { el.classList.add("heat--paused"); @@ -254,6 +270,9 @@ export default class extends Controller { updateHeatmap(initial = false) { const d = this.viz; + if (!d.areas.length || !d.departments.length) { + return; + } let max = 1; d.heatmap.forEach((row) => row.forEach((v) => { @@ -308,7 +327,7 @@ export default class extends Controller { el.innerHTML = ""; if (!this.viz.collaboration.length) { el.innerHTML = - 'Ingen tværgående temaer endnu — kategorisér initiativer for at finde sammenfald.
'; + 'Ingen tværgående områder endnu — kategorisér initiativer for at finde sammenfald.
'; return; } // Hold the entrance paused until the panel scrolls into view; once seen, diff --git a/assets/images/itk-logo.png b/assets/images/itk-logo.png new file mode 100644 index 0000000..ca95a6a Binary files /dev/null and b/assets/images/itk-logo.png differ diff --git a/assets/styles/app.css b/assets/styles/app.css index cfc9177..599c076 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -129,12 +129,12 @@ h3 { } .top-nav__brand { - font-size: var(--itk-text-base); - font-weight: 700; - color: var(--itk-ink); display: inline-flex; align-items: center; gap: var(--itk-space-2); + font-size: var(--itk-text-base); + font-weight: 700; + color: var(--itk-ink); letter-spacing: -0.01em; } @@ -143,19 +143,25 @@ h3 { text-decoration: none; } -.top-nav__brand-mark { - width: 28px; - height: 28px; - border-radius: var(--itk-radius-2); - background: linear-gradient(135deg, var(--itk-blue), var(--itk-cyan)); - display: inline-block; +.top-nav__brand-logo { + height: 26px; + width: auto; + display: block; } .top-nav__menu { display: flex; align-items: center; gap: var(--itk-space-1); - margin-left: var(--itk-space-4); +} + +@media (min-width: 1520px) { + .top-nav__menu { + position: absolute; + top: 0; + left: calc((100vw - var(--itk-container)) / 2 + var(--itk-space-5)); + height: 64px; + } } .top-nav__link { @@ -471,13 +477,31 @@ h3 { padding: var(--itk-space-5); } +.card--stretch { + align-self: stretch; + display: flex; + flex-direction: column; +} + +.feed-fill { + position: relative; + flex: 1; + min-height: 230px; +} + +.feed-fill .feed-scroll { + position: absolute; + inset: 0; + height: auto; +} + .card__header { padding: var(--itk-space-4) var(--itk-space-5); border-bottom: 1px solid var(--itk-slate-100); display: flex; - align-items: center; - justify-content: space-between; - gap: var(--itk-space-3); + flex-direction: column; + align-items: flex-start; + gap: var(--itk-space-1); } .card__title { @@ -573,7 +597,8 @@ h3 { .card__sub { font-size: var(--itk-text-sm); color: var(--itk-slate-500); - margin: 0 0 var(--itk-space-4); + margin: 0; + line-height: 1.4; } .feed-scroll { @@ -609,6 +634,12 @@ h3 { min-width: 560px; } +.heat-empty { + margin: 0; + color: var(--itk-slate-500); + font-size: var(--itk-text-sm); +} + .heat__collabel { display: flex; flex-direction: column; @@ -844,6 +875,75 @@ h3 { color: var(--itk-slate-500); } +a.recent-item { + color: inherit; + text-decoration: none; + transition: background-color 0.15s ease; +} + +a.recent-item:hover { + background-color: color-mix(in srgb, var(--itk-blue) 9%, transparent); +} + +.recent-item__cta { + display: none; + align-items: center; + gap: 4px; + font-size: var(--itk-text-xs); + font-weight: 600; + color: var(--itk-blue); + white-space: nowrap; +} + +a.recent-item:hover .badge { + display: none; +} + +a.recent-item:hover .recent-item__cta { + display: inline-flex; +} + +.recent-item__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.recent-item__body .recent-item__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.todo-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--itk-space-2); + height: 100%; + padding: var(--itk-space-5); + text-align: center; +} + +.todo-empty__icon { + color: var(--itk-green); + line-height: 0; +} + +.todo-empty__title { + font-weight: 600; + color: var(--itk-ink); +} + +.todo-empty__hint { + max-width: 34ch; + margin: 0 0 var(--itk-space-2); + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + .table-wrap { overflow-x: auto; } @@ -1677,15 +1777,25 @@ body.is-lightbox-open { .auth__brand { display: flex; align-items: center; + justify-content: center; gap: var(--itk-space-2); - font-weight: 700; font-size: var(--itk-text-md); - margin-bottom: var(--itk-space-2); + font-weight: 700; + color: var(--itk-ink); + margin-bottom: var(--itk-space-6); +} + +.auth__logo { + height: 40px; + width: auto; + display: block; } .auth__title { - font-size: var(--itk-text-xl); - margin-bottom: var(--itk-space-6); + font-size: var(--itk-text-base); + font-weight: 600; + text-align: center; + margin-bottom: var(--itk-space-3); } .auth .form-row { diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 8d63386..2053b0d 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Controller; +use App\Entity\User; +use App\Repository\ContactRepository; use App\Repository\InitiativeRepository; use App\Service\DashboardData; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -13,11 +15,15 @@ class DashboardController extends AbstractController { #[Route('/', name: 'app_dashboard', methods: ['GET'])] - public function index(InitiativeRepository $initiatives, DashboardData $dashboardData): Response + public function index(InitiativeRepository $initiatives, ContactRepository $contacts, DashboardData $dashboardData): Response { + $user = $this->getUser(); + return $this->render('dashboard/index.html.twig', [ 'recent' => $initiatives->findRecent(8), 'viz' => $dashboardData->build(), + 'unfinishedInitiatives' => $user instanceof User ? $initiatives->findUnfinishedListByCreator($user) : [], + 'incompleteContacts' => $user instanceof User ? $contacts->findIncompleteListByCreator($user) : [], ]); } } diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php index 559dcf7..a274d9c 100644 --- a/src/Entity/Initiative.php +++ b/src/Entity/Initiative.php @@ -500,6 +500,24 @@ public function getCompletionPercentage(): int return (int) round(\count(array_filter($checks)) / \count($checks) * 100); } + /** + * Whether the initiative has been edited since it was created — drives the + * "updated" vs "created" label in the dashboard activity feed. Only counts as + * an edit once it's more than a day past creation, so the initial save and any + * same-day tweaks still read as "created". + */ + public function wasUpdatedAfterCreation(): bool + { + $created = $this->getCreatedAt(); + $updated = $this->getUpdatedAt(); + + if (null === $created || null === $updated) { + return false; + } + + return $updated->getTimestamp() - $created->getTimestamp() > 60 * 60 * 24; + } + public function __toString(): string { return (string) $this->title; diff --git a/src/Repository/ContactRepository.php b/src/Repository/ContactRepository.php index a7cd004..8aa5f2f 100644 --- a/src/Repository/ContactRepository.php +++ b/src/Repository/ContactRepository.php @@ -63,6 +63,18 @@ public function findOrCreate(string $name): Contact * Used by the mascot to nudge them to fill in the rest. */ public function findIncompleteByCreator(User $user): ?Contact + { + return $this->findIncompleteListByCreator($user, 1)[0] ?? null; + } + + /** + * The user's contacts that still lack an email (created name-only and not yet + * finished), most recent first, capped at $limit. Surfaced on the dashboard + * as outstanding work. + * + * @return Contact[] + */ + public function findIncompleteListByCreator(User $user, int $limit = 6): array { // createdBy is a ManyToOne to the UserInterface (resolved to User via // resolve_target_entities); binding the entity to a ULID FK doesn't match, @@ -72,8 +84,8 @@ public function findIncompleteByCreator(User $user): ?Contact ->andWhere("(c.email IS NULL OR c.email = '')") ->setParameter('user', $user->getId(), 'ulid') ->orderBy('c.createdAt', 'DESC') - ->setMaxResults(1) + ->setMaxResults($limit) ->getQuery() - ->getOneOrNullResult(); + ->getResult(); } } diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index af4636e..18ce917 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -180,10 +180,21 @@ public function countByCreator(User $user): int /** * The creator's least-complete initiative that isn't fully filled in yet, or - * null if none are outstanding. Completion is computed in PHP (not a stored - * column), so this scans only the creator's 50 most recent initiatives. + * null if none are outstanding. */ public function findUnfinishedByCreator(User $user): ?Initiative + { + return $this->findUnfinishedListByCreator($user, 1)[0] ?? null; + } + + /** + * The creator's incomplete initiatives (completion below 100 %), least-complete + * first, capped at $limit. Completion is computed in PHP (not a stored column), + * so this scans only the creator's 50 most recent initiatives. + * + * @return Initiative[] + */ + public function findUnfinishedListByCreator(User $user, int $limit = 6): array { // See countByCreator: match the raw ULID FK, not the entity. $initiatives = $this->createQueryBuilder('i') @@ -204,7 +215,7 @@ public function findUnfinishedByCreator(User $user): ?Initiative static fn (Initiative $a, Initiative $b): int => $a->getCompletionPercentage() <=> $b->getCompletionPercentage(), ); - return $unfinished[0] ?? null; + return \array_slice($unfinished, 0, $limit); } /** @@ -232,12 +243,15 @@ public function countByStatus(): array } /** + * Most recently touched initiatives (created or edited) for the activity feed, + * newest first. Ordered by updatedAt so an edit resurfaces the initiative. + * * @return Initiative[] */ public function findRecent(int $limit = 5): array { return $this->createQueryBuilder('i') - ->orderBy('i.createdAt', 'DESC') + ->orderBy('i.updatedAt', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/templates/activity/_item.html.twig b/templates/activity/_item.html.twig index 8adc949..8afa337 100644 --- a/templates/activity/_item.html.twig +++ b/templates/activity/_item.html.twig @@ -1,9 +1,9 @@ -