feat(plan): render category colors from DB via --cat-color

Replace 14 hardcoded per-category CSS rules with a single --cat-color
custom property set inline on each filled cell button, driven by the DB
categories list threaded from PlanWork → PlanGrid → PlanRangeChips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 00:47:53 +02:00
parent 4b1c42b17f
commit 8b77a0a905
4 changed files with 54 additions and 97 deletions

View File

@@ -1,5 +1,12 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { GridData, ResolvedCell, planCategoryLabel } from "../lib/queries/plan"; import type { CSSProperties } from "react";
import {
GridData,
ResolvedCell,
planCategoryLabel,
categoryMap,
PlanCategory,
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects"; import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips"; import PlanRangeChips from "./PlanRangeChips";
@@ -7,6 +14,7 @@ interface Props {
data: GridData | undefined; data: GridData | undefined;
canEdit: boolean; canEdit: boolean;
projects: Project[]; projects: Project[];
categories: PlanCategory[];
// The (userId, date) of the most recent successful mutation. The // The (userId, date) of the most recent successful mutation. The
// matching cell gets a one-shot highlight pulse. `nonce` is part of // matching cell gets a one-shot highlight pulse. `nonce` is part of
// the value so back-to-back mutations on the same cell re-trigger // the value so back-to-back mutations on the same cell re-trigger
@@ -92,10 +100,12 @@ export default function PlanGrid({
data, data,
canEdit, canEdit,
projects, projects,
categories,
pulseKey, pulseKey,
onCellClick, onCellClick,
}: Props) { }: Props) {
const today = useMemo(() => todayIso(), []); const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]);
if (!data) if (!data)
return ( return (
@@ -206,10 +216,17 @@ export default function PlanGrid({
<button <button
type="button" type="button"
className={cls} className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cell)} onClick={() => onCellClick(u.id, date, cell)}
aria-label={ aria-label={
cell cell
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category)}` ? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: isLocked : isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu` ? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam` : `${u.full_name}, ${date}, prázdné — přidat záznam`
@@ -225,6 +242,9 @@ export default function PlanGrid({
: null : null
} }
readonly={!canEdit} readonly={!canEdit}
categoryLabel={
cell ? planCategoryLabel(cell.category, catMap) : ""
}
/> />
</button> </button>
</td> </td>

View File

@@ -1,17 +1,19 @@
import { import { ResolvedCell, cellProjectLabel } from "../lib/queries/plan";
ResolvedCell,
planCategoryLabel,
cellProjectLabel,
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects"; import type { Project } from "../lib/queries/projects";
interface Props { interface Props {
cell: ResolvedCell | null; cell: ResolvedCell | null;
project: Project | null; project: Project | null;
readonly?: boolean; readonly?: boolean;
categoryLabel: string;
} }
export default function PlanRangeChips({ cell, project, readonly }: Props) { export default function PlanRangeChips({
cell,
project,
readonly,
categoryLabel,
}: Props) {
void readonly; void readonly;
if (!cell) return null; if (!cell) return null;
// Prefer the server-embedded project label (always present). Fall back to // Prefer the server-embedded project label (always present). Fall back to
@@ -27,9 +29,7 @@ export default function PlanRangeChips({ cell, project, readonly }: Props) {
return ( return (
<div className="plan-chip-block"> <div className="plan-chip-block">
<div className="plan-chip-line"> <div className="plan-chip-line">
<span className={`plan-chip plan-chip--${cell.category}`}> <span className="plan-chip">{categoryLabel}</span>
{planCategoryLabel(cell.category)}
</span>
{projectLabel && ( {projectLabel && (
<span className="plan-chip-project" title={projectTitle}> <span className="plan-chip-project" title={projectTitle}>
{projectLabel} {projectLabel}

View File

@@ -13,6 +13,7 @@ import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal"; import PlanCellModal from "../components/PlanCellModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { projectListOptions, type Project } from "../lib/queries/projects"; import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
import "../plan.css"; import "../plan.css";
const MONTH_NAMES = [ const MONTH_NAMES = [
@@ -125,6 +126,9 @@ export default function PlanWork() {
const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 })); const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
const projects: Project[] = (projectsData?.data as Project[]) ?? []; const projects: Project[] = (projectsData?.data as Project[]) ?? [];
const { data: categoriesData } = useQuery(planCategoriesOptions());
const categories: PlanCategory[] = categoriesData ?? [];
// `lastMutated` is the (userId, date) of the most recent successful // `lastMutated` is the (userId, date) of the most recent successful
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell // mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
// gets a one-shot CSS pulse animation. We clear it after 700ms (the // gets a one-shot CSS pulse animation. We clear it after 700ms (the
@@ -667,6 +671,7 @@ export default function PlanWork() {
<PlanGrid <PlanGrid
data={grid} data={grid}
projects={projects} projects={projects}
categories={categories}
canEdit={canEdit} canEdit={canEdit}
pulseKey={lastMutated} pulseKey={lastMutated}
onCellClick={openCell} onCellClick={openCell}

View File

@@ -418,41 +418,13 @@
bottom: 8px; bottom: 8px;
width: 3px; width: 3px;
border-radius: 2px; border-radius: 2px;
background: var(--plan-paper-rule); background: var(--cat-color, var(--plan-paper-rule));
transition: transition:
background var(--motion-fast) ease, background var(--motion-fast) ease,
box-shadow var(--motion-fast) ease, box-shadow var(--motion-fast) ease,
transform var(--motion-fast) ease; transform var(--motion-fast) ease;
} }
/* When the cell has a chip, paint the tape to match the category. We use
the chip's class — but since the tape lives on the *button* and the
category class lives on the inner chip span, we paint it from a custom
property set on the button via JS-free attribute mapping. As a simpler
approach, color the tape by reading the .plan-chip--* class of the
*first descendant* with a CSS selector — modern browsers support :has(). */
.plan-cell:has(.plan-chip--work)::before {
background: var(--plan-tape-work);
}
.plan-cell:has(.plan-chip--preparation)::before {
background: var(--plan-tape-preparation);
}
.plan-cell:has(.plan-chip--travel)::before {
background: var(--plan-tape-travel);
}
.plan-cell:has(.plan-chip--leave)::before {
background: var(--plan-tape-leave);
}
.plan-cell:has(.plan-chip--sick)::before {
background: var(--plan-tape-sick);
}
.plan-cell:has(.plan-chip--training)::before {
background: var(--plan-tape-training);
}
.plan-cell:has(.plan-chip--other)::before {
background: var(--plan-tape-other);
}
.plan-cell:hover { .plan-cell:hover {
background: var(--plan-cell-bg-hover); background: var(--plan-cell-bg-hover);
} }
@@ -677,67 +649,27 @@
font-family: var(--plan-font-body); font-family: var(--plan-font-body);
/* Category chip is small, monoline. The heavy color lives on the tape /* Category chip is small, monoline. The heavy color lives on the tape
on the cell edge — the chip is the *text* version of the same color. */ on the cell edge — the chip is the *text* version of the same color. */
color: var(--plan-ink); color: var(--cat-color, var(--plan-ink));
background: color-mix(in srgb, var(--plan-paper) 60%, var(--plan-paper-soft)); background: color-mix(
border: 1px solid var(--plan-paper-rule); in srgb,
var(--cat-color, var(--plan-paper-soft)) 10%,
var(--plan-paper)
);
border: 1px solid
color-mix(
in srgb,
var(--cat-color, var(--plan-paper-rule)) 35%,
transparent
);
white-space: nowrap; white-space: nowrap;
} }
/* Category chips inherit the cell's tape color via :has() */
.plan-cell:has(.plan-chip--work) .plan-chip--work {
color: var(--plan-tape-work);
border-color: color-mix(in srgb, var(--plan-tape-work) 35%, transparent);
background: color-mix(in srgb, var(--plan-tape-work) 10%, var(--plan-paper));
}
.plan-cell:has(.plan-chip--preparation) .plan-chip--preparation {
color: var(--plan-tape-preparation);
border-color: color-mix(
in srgb,
var(--plan-tape-preparation) 35%,
transparent
);
background: color-mix(
in srgb,
var(--plan-tape-preparation) 10%,
var(--plan-paper)
);
}
.plan-cell:has(.plan-chip--travel) .plan-chip--travel {
color: var(--plan-tape-travel);
border-color: color-mix(in srgb, var(--plan-tape-travel) 35%, transparent);
background: color-mix(
in srgb,
var(--plan-tape-travel) 10%,
var(--plan-paper)
);
}
.plan-cell:has(.plan-chip--leave) .plan-chip--leave {
color: var(--plan-tape-leave);
border-color: color-mix(in srgb, var(--plan-tape-leave) 35%, transparent);
background: color-mix(in srgb, var(--plan-tape-leave) 10%, var(--plan-paper));
}
.plan-cell:has(.plan-chip--sick) .plan-chip--sick {
color: var(--plan-tape-sick);
border-color: color-mix(in srgb, var(--plan-tape-sick) 35%, transparent);
background: color-mix(in srgb, var(--plan-tape-sick) 10%, var(--plan-paper));
}
.plan-cell:has(.plan-chip--training) .plan-chip--training {
color: var(--plan-tape-training);
border-color: color-mix(in srgb, var(--plan-tape-training) 35%, transparent);
background: color-mix(
in srgb,
var(--plan-tape-training) 10%,
var(--plan-paper)
);
}
.plan-cell:has(.plan-chip--other) .plan-chip--other {
color: var(--plan-tape-other);
border-color: color-mix(in srgb, var(--plan-tape-other) 35%, transparent);
background: color-mix(in srgb, var(--plan-tape-other) 10%, var(--plan-paper));
}
[data-theme="dark"] .plan-chip { [data-theme="dark"] .plan-chip {
background: color-mix(in srgb, var(--plan-cell-bg) 90%, var(--plan-paper)); background: color-mix(
in srgb,
var(--cat-color, var(--plan-cell-bg)) 16%,
var(--plan-paper)
);
} }
.plan-chip-project { .plan-chip-project {