add dashboard calendar week strip; styles

This commit is contained in:
2026-05-29 20:44:10 +00:00
parent 8c4fd72a27
commit e13d8483b0
5 changed files with 321 additions and 96 deletions
+3 -3
View File
@@ -957,7 +957,7 @@ export function App() {
<header className="catalog-header">
<div className="catalog-brand">
<span className="catalog-logo">Plant-Man</span>
<span className="catalog-subtitle">Plant Care Supply</span>
<span className="catalog-subtitle">Plant care workbench</span>
</div>
<div className="catalog-contact">
<strong>{plants.length}</strong>
@@ -967,7 +967,7 @@ export function App() {
<div className="catalog-layout">
<aside className="catalog-sidebar">
<h2>Concepts</h2>
<h2>Workbench</h2>
<nav className="catalog-nav" aria-label="Primary navigation">
<div className="nav-group">
<h3>Operations</h3>
@@ -1297,7 +1297,7 @@ function getViewTitle(view: View) {
case 'flags':
return 'Plant Flags';
default:
return 'Plant-Man';
return 'Dashboard';
}
}
@@ -1,3 +1,4 @@
import { useMemo, useState } from 'react';
import { CalendarCheck, Plus } from 'lucide-react';
import type { CareTask, Plant } from '../domain';
import { PlantCard } from './PlantCard';
@@ -26,6 +27,10 @@ export function HomeView({
onOpenPlant,
}: HomeViewProps) {
const dueTaskGroups = groupDueTasks(careTasks);
const weekDays = useMemo(() => getWeekDays(), []);
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date()));
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0];
const selectedTasks = getTasksForDate(careTasks, selectedDate);
return (
<>
@@ -39,6 +44,62 @@ export function HomeView({
</div>
</section>
<section className="section" aria-labelledby="calendar-heading">
<div className="section-heading">
<h2 id="calendar-heading">Care Calendar</h2>
<span className="schedule-count">{selectedTasks.length} selected</span>
</div>
<div className="week-strip" role="tablist" aria-label="Care tasks by day">
{weekDays.map((day) => {
const dayTasks = getTasksForDate(careTasks, day.dateKey);
const hasDueTasks = dayTasks.some((task) => task.status === 'due');
return (
<button
className="week-day"
type="button"
role="tab"
aria-selected={day.dateKey === selectedDate}
key={day.dateKey}
onClick={() => setSelectedDate(day.dateKey)}
>
<span>{day.label}</span>
<strong>{day.dayNumber}</strong>
<small>{dayTasks.length}</small>
{hasDueTasks ? <span className="week-day-alert" aria-hidden="true" /> : null}
</button>
);
})}
</div>
<div className="task-list">
<p className="task-list-label">{selectedDay?.heading ?? 'Selected day'}</p>
{!isLoading && selectedTasks.length === 0 ? (
<p className="empty-state">No care scheduled for this day.</p>
) : null}
{selectedTasks.map((task) => (
<article className="task-row" key={`calendar-${task.id}`}>
<span className={`status-dot ${task.status}`} />
<div>
<h3>{task.action}</h3>
<p>{task.plantName} - {task.due}</p>
</div>
<button
className="small-action"
type="button"
disabled={task.status !== 'due'}
onClick={() => onCompleteTask(task)}
>
<CalendarCheck size={16} />
Log
</button>
</article>
))}
</div>
</section>
<section className="section" aria-labelledby="care-heading">
<div className="section-heading">
<h2 id="care-heading">Due Care</h2>
@@ -153,3 +214,64 @@ function formatPlantNames(tasks: CareTask[]) {
return `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`;
}
function getWeekDays() {
const today = new Date();
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() + index);
const dateKey = getDateKey(date);
return {
dateKey,
dayNumber: date.getDate().toString(),
heading: index === 0
? 'Today'
: date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' }),
label: index === 0
? 'Today'
: date.toLocaleDateString(undefined, { weekday: 'short' }),
};
});
}
function getTasksForDate(tasks: CareTask[], dateKey: string) {
const todayKey = getDateKey(new Date());
return tasks
.filter((task) => {
if (!task.dueDate) {
return false;
}
if (dateKey === todayKey) {
return task.dueDate <= todayKey;
}
return task.dueDate === dateKey;
})
.sort(compareCareTasks);
}
function compareCareTasks(left: CareTask, right: CareTask) {
const dueComparison = (left.dueDate ?? '').localeCompare(right.dueDate ?? '');
if (dueComparison !== 0) {
return dueComparison;
}
const plantComparison = left.plantName.localeCompare(right.plantName);
if (plantComparison !== 0) {
return plantComparison;
}
return left.action.localeCompare(right.action);
}
function getDateKey(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
+1
View File
@@ -214,6 +214,7 @@ export type CareTask = {
careActivityId: number;
careActionId: number;
action: string;
dueDate: string | null;
due: string;
status: CareStatus;
};
+193 -93
View File
@@ -1,24 +1,26 @@
:root {
color: #161616;
background: #f4f4f4;
color: #24342c;
background: #f6f5ef;
font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--border: #e0e0e0;
--rule: #c6c6c6;
--muted: #6f6f6f;
--panel: #fff;
--side: #f4f4f4;
--row: #f4f4f4;
--hover: #e8f0fe;
--link: #0f62fe;
--focus: #0f62fe;
--field: #f4f4f4;
--layer: #fff;
--warning: #8a5a00;
--danger: #da1e28;
--ok: #198038;
--border: #ded8ca;
--rule: #cbbfaaaa;
--muted: #6d776c;
--panel: #fffdf7;
--side: #efeee5;
--row: #f8f6ee;
--hover: #eef4e8;
--link: #3d6f4b;
--focus: #2f6f7e;
--field: #f5f3eb;
--layer: #fffdf7;
--warning: #9a6508;
--danger: #b44336;
--ok: #3f7d55;
--soil: #7c6144;
--sage: #dbe7d2;
}
* {
@@ -30,7 +32,9 @@ body {
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
background: #f4f4f4;
background:
linear-gradient(180deg, rgba(219, 231, 210, 0.55), rgba(246, 245, 239, 0) 260px),
#f6f5ef;
font-size: 13px;
}
@@ -115,12 +119,12 @@ p {
grid-template-columns: minmax(190px, 260px) minmax(260px, 680px) minmax(120px, 1fr);
align-items: center;
gap: 18px;
min-height: 64px;
padding: 10px 22px;
border-bottom: 0;
background: #161616;
color: #f4f4f4;
box-shadow: none;
min-height: 68px;
padding: 12px 24px;
border-bottom: 1px solid #274333;
background: #1f3528;
color: #fffdf7;
box-shadow: 0 1px 0 rgba(36, 52, 44, 0.08);
}
.catalog-brand {
@@ -129,7 +133,7 @@ p {
}
.catalog-logo {
color: #fff;
color: #fffdf7;
font-size: 1.65rem;
font-weight: 600;
line-height: 1;
@@ -137,7 +141,7 @@ p {
.catalog-subtitle,
.catalog-contact span {
color: #c6c6c6;
color: #c8d7c3;
font-size: 0.78rem;
font-weight: 500;
}
@@ -145,7 +149,7 @@ p {
.catalog-contact {
display: grid;
justify-items: end;
color: #fff;
color: #fffdf7;
white-space: nowrap;
}
@@ -163,13 +167,14 @@ p {
margin: 0 auto;
border-right: 1px solid var(--border);
border-left: 1px solid var(--border);
background: var(--panel);
}
.catalog-sidebar {
position: sticky;
top: 64px;
top: 68px;
min-width: 0;
min-height: calc(100vh - 64px);
min-height: calc(100vh - 68px);
padding: 16px 16px 24px 22px;
border-right: 1px solid var(--border);
background: var(--side);
@@ -178,7 +183,7 @@ p {
.catalog-sidebar h2,
.catalog-help h3 {
margin: 0 0 8px;
color: #161616;
color: #24342c;
font-size: 0.95rem;
}
@@ -207,9 +212,10 @@ p {
min-width: 0;
min-height: 36px;
padding: 0 12px;
border: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #525252;
color: #4f5f54;
font-size: 0.9rem;
font-weight: 400;
text-align: left;
@@ -232,17 +238,19 @@ p {
}
.catalog-nav button:hover {
background: #e8e8e8;
color: #161616;
border-color: #d2c8b7;
background: #f7f4ea;
color: #24342c;
}
.text-button:hover {
color: #0043ce;
color: #294f36;
}
.catalog-nav button[aria-current='page'] {
background: #e0e0e0;
color: #161616;
border-color: #c4d5bc;
background: var(--sage);
color: #24342c;
font-weight: 500;
}
@@ -252,15 +260,16 @@ p {
bottom: 0;
left: 0;
width: 3px;
background: var(--link);
border-radius: 999px;
background: var(--ok);
content: '';
}
.catalog-help {
margin-top: 22px;
padding-top: 12px;
border-top: 1px solid #cfcfcf;
color: #333;
border-top: 1px solid #d2c8b7;
color: #4d5a51;
font-size: 0.86rem;
}
@@ -270,8 +279,8 @@ p {
.catalog-main {
min-width: 0;
padding: 16px 22px 36px;
background: #fff;
padding: 18px 22px 40px;
background: var(--panel);
}
.catalog-page-title {
@@ -285,7 +294,8 @@ p {
.catalog-page-title h1 {
margin: 0 0 6px;
font-weight: 500;
color: #24342c;
font-weight: 600;
}
.content {
@@ -310,7 +320,7 @@ p {
.plant-row,
.task-row {
border: 1px solid var(--border);
border-radius: 0;
border-radius: 8px;
background: var(--panel);
box-shadow: none;
}
@@ -320,8 +330,8 @@ p {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-top: 0;
padding: 12px 14px;
border-left: 4px solid var(--link);
}
.summary-panel h2 {
@@ -331,7 +341,7 @@ p {
.summary-panel p {
max-width: 68ch;
color: #444;
color: #59675d;
font-size: 0.86rem;
}
@@ -342,7 +352,8 @@ p {
}
.editor-panel {
padding: 10px 12px;
padding: 12px;
background: #fbfaf4;
}
.section-heading,
@@ -364,10 +375,10 @@ p {
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 0;
background: #e0e0e0;
color: #161616;
border: 1px solid #cfc4b4;
border-radius: 6px;
background: #f4f0e5;
color: #24342c;
box-shadow: none;
font-weight: 500;
}
@@ -376,7 +387,8 @@ p {
min-height: 34px;
padding: 0 12px;
background: var(--link);
color: #fff;
border-color: var(--link);
color: #fffdf7;
}
.small-action {
@@ -402,12 +414,13 @@ p {
}
.primary-action:hover:not(:disabled) {
background: #0353e9;
background: #315c3d;
}
.small-action:hover:not(:disabled),
.icon-button:hover:not(:disabled) {
background: #d0d0d0;
border-color: #b8aa98;
background: #e9e2d4;
}
.plant-mark svg,
@@ -419,6 +432,76 @@ p {
height: 15px;
}
.week-strip {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.week-day {
position: relative;
display: grid;
min-width: 0;
min-height: 74px;
align-content: space-between;
gap: 4px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf4;
color: #24342c;
text-align: left;
}
.week-day:hover {
border-color: #b8aa98;
background: var(--hover);
}
.week-day[aria-selected='true'] {
border-color: var(--link);
background: var(--sage);
box-shadow: inset 0 0 0 1px var(--link);
}
.week-day span:first-child,
.week-day small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
}
.week-day strong {
color: #24342c;
font-size: 1.35rem;
line-height: 1;
}
.week-day small {
display: inline-flex;
width: fit-content;
min-width: 22px;
min-height: 20px;
align-items: center;
justify-content: center;
padding: 0 6px;
border-radius: 999px;
background: #ece6d9;
color: #4f5f54;
}
.week-day-alert {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--danger);
}
.task-list,
.plant-list,
.plant-grid,
@@ -426,7 +509,7 @@ p {
display: grid;
gap: 0;
border-top: 1px solid var(--border);
background: #fff;
background: var(--panel);
}
.task-row,
@@ -435,8 +518,9 @@ p {
.detail-row {
min-height: 42px;
border: 0;
border-radius: 0;
border-bottom: 1px solid var(--border);
background: #fff;
background: var(--panel);
}
.task-row:nth-child(even),
@@ -462,14 +546,14 @@ p {
}
.task-row-group {
background: #f4f4f4;
background: #f3efe3;
}
.task-list-label {
margin: 0;
padding: 7px 8px 5px;
border-bottom: 1px solid var(--border);
background: #f4f4f4;
background: #f3efe3;
color: var(--muted);
font-size: 0.76rem;
font-weight: 500;
@@ -510,7 +594,7 @@ p {
display: inline;
min-width: 0;
overflow: hidden;
color: #444;
color: #59675d;
font-size: 0.86rem;
line-height: 1.25;
text-overflow: ellipsis;
@@ -549,7 +633,7 @@ p {
.detail-row p,
.taxon,
.empty-state {
color: #444;
color: #59675d;
font-size: 0.86rem;
line-height: 1.25;
}
@@ -599,13 +683,13 @@ dd {
.empty-state {
padding: 8px;
border-bottom: 1px solid var(--border);
background: #f4f4f4;
background: #f8f6ee;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 0;
border-radius: 999px;
}
.status-dot.due {
@@ -626,29 +710,29 @@ dd {
align-items: center;
padding: 0 6px;
border: 1px solid currentColor;
border-radius: 0;
background: #fff;
border-radius: 999px;
background: var(--panel);
font-size: 0.72rem;
font-weight: 500;
}
.status-pill.due {
background: #fff1f1;
background: #fbe9e4;
color: var(--danger);
}
.status-pill.soon {
background: #fff8d6;
background: #fbefd2;
color: var(--warning);
}
.status-pill.ok {
background: #defbe6;
background: #e5f1df;
color: var(--ok);
}
.status-pill.unscheduled {
background: #e0e0e0;
background: #ece6d9;
color: var(--muted);
}
@@ -663,7 +747,7 @@ dd {
.flag-assignment-form label {
display: grid;
gap: 4px;
color: #525252;
color: #4f5f54;
font-size: 0.78rem;
font-weight: 500;
}
@@ -675,11 +759,10 @@ dd {
width: 100%;
min-height: 34px;
padding: 0 8px;
border: 0;
border-bottom: 1px solid #8d8d8d;
border-radius: 0;
border: 1px solid #cfc4b4;
border-radius: 6px;
background: var(--field);
color: #161616;
color: #24342c;
box-shadow: none;
}
@@ -699,7 +782,8 @@ dd {
align-self: end;
padding: 5px 7px;
border: 1px solid var(--border);
background: #f4f4f4;
border-radius: 6px;
background: #f8f6ee;
}
.plant-form .toggle-field input,
@@ -722,12 +806,13 @@ dd {
margin: 0;
padding: 12px;
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
background: var(--panel);
}
.resource-picker legend {
padding: 0 4px;
color: #161616;
color: #24342c;
font-size: 0.82rem;
font-weight: 500;
}
@@ -777,7 +862,7 @@ dd {
padding: 0;
border: 0;
border-bottom: 1px solid var(--border);
background: #fff;
background: var(--panel);
}
.activity-resource-header,
@@ -850,7 +935,7 @@ dd {
.schedule-hint {
margin: 8px 0 0;
color: #444;
color: #59675d;
font-size: 0.86rem;
}
@@ -860,7 +945,8 @@ dd {
margin-top: 10px;
padding: 8px;
border-left: 3px solid var(--link);
background: #f4f4f4;
border-radius: 0 6px 6px 0;
background: #f3efe3;
}
.schedule-preview span {
@@ -871,7 +957,7 @@ dd {
}
.schedule-preview strong {
color: #161616;
color: #24342c;
font-size: 0.9rem;
font-weight: 500;
}
@@ -890,7 +976,8 @@ dd {
margin: 10px 0 0;
padding: 8px;
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
background: var(--panel);
}
.schedule-options legend,
@@ -919,8 +1006,8 @@ dd {
.schedule-end-options input[type='number'] {
min-height: 34px;
padding: 0 8px;
border: 0;
border-bottom: 1px solid #8d8d8d;
border: 1px solid #cfc4b4;
border-radius: 6px;
background: var(--field);
}
@@ -930,8 +1017,9 @@ dd {
height: 28px;
place-items: center;
border: 1px solid var(--border);
background: #f4f4f4;
color: #161616;
border-radius: 6px;
background: #f8f6ee;
color: #24342c;
font-size: 0.78rem;
}
@@ -942,7 +1030,7 @@ dd {
.weekday-options label:has(input:checked) {
border-color: var(--link);
background: #e8f0ff;
background: var(--sage);
color: var(--link);
}
@@ -959,11 +1047,10 @@ dd {
width: 100%;
min-height: 34px;
padding: 0 8px;
border: 0;
border-bottom: 1px solid #8d8d8d;
border-radius: 0;
border: 1px solid #cfc4b4;
border-radius: 6px;
background: var(--field);
color: #161616;
color: #24342c;
}
.compact-plant-list {
@@ -1019,7 +1106,7 @@ dd {
}
.plant-detail-meta strong {
color: #161616;
color: #24342c;
font-size: 0.9rem;
line-height: 1.2;
}
@@ -1090,7 +1177,8 @@ dd {
align-items: center;
padding: 0 7px;
border: 1px solid #999;
color: #161616;
border-radius: 999px;
color: #24342c;
font-size: 0.74rem;
font-weight: 500;
line-height: 1;
@@ -1100,7 +1188,8 @@ dd {
margin-top: 8px;
padding: 8px;
border-bottom: 1px solid var(--border);
background: #f4f4f4;
border-radius: 8px;
background: #f8f6ee;
}
.flag-assignment-form label {
@@ -1199,6 +1288,17 @@ dd {
padding: 12px;
}
.week-strip {
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 2px;
}
.week-day {
flex: 0 0 78px;
}
.catalog-page-title {
display: block;
}
+2
View File
@@ -350,6 +350,7 @@ namespace plant_manager
int CareActivityId,
int CareActionId,
string Action,
string? DueDate,
string Due,
string Status)
{
@@ -368,6 +369,7 @@ namespace plant_manager
schedule.CareActivityId,
schedule.CareActionId,
schedule.CareActivity.Name,
nextCare?.ToString("yyyy-MM-dd"),
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
PlantCareFormatter.GetStatus(nextCare, today));
}