task ep1.6.1
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Task EP.1.6.1 Calendar Empty State Rendering Improvement - 2026-07-13
|
||||
|
||||
## Scope Delivered
|
||||
|
||||
- updated Calendar Day, Week, and Month views to always render calendar grid cells even when there are zero events
|
||||
- kept Agenda view list-oriented empty state behavior unchanged
|
||||
- added inline empty overlay for Day/Week/Month instead of replacing the calendar surface
|
||||
- added `+ Add Activity` entry point from empty calendar overlay using existing Activity workspace route
|
||||
- kept date/time rendering through `src/lib/date-format.ts`
|
||||
- preserved Calendar projection, Activity ownership, and Calendar adapter architecture
|
||||
|
||||
## Review Summary
|
||||
|
||||
Reviewed before implementation:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `plans/task-ep.1.6.1.md`
|
||||
- `docs/implementation/task-ep1.6-calendar-projection-workspace-big-calendar-foundation-2026-07-13.md`
|
||||
- `docs/implementation/task-ep1.7-my-day-workspace-foundation-2026-07-13.md`
|
||||
- `src/features/crm/calendar/components/calendar-workspace.tsx`
|
||||
- Calendar API contracts under `src/features/crm/calendar/api/types.ts`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Day view now renders 12 working-hour slots.
|
||||
- Week view now renders seven day cells.
|
||||
- Month view now renders a 42-cell month grid.
|
||||
- Empty Day/Week/Month views show a lightweight non-blocking overlay with Add Activity action.
|
||||
- Calendar cells remain rendered and selectable via button semantics.
|
||||
- Agenda retains `CalendarEmptyState` because a list view should show a list empty state.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npx oxlint src/features/crm/calendar/components/calendar-workspace.tsx`
|
||||
|
||||
## Outcome
|
||||
|
||||
Empty graphical Calendar views now feel like available scheduling capacity rather than a missing screen.
|
||||
248
plans/task-ep.1.6.1.md
Normal file
248
plans/task-ep.1.6.1.md
Normal file
@@ -0,0 +1,248 @@
|
||||
## Calendar Empty State Rendering Improvement
|
||||
|
||||
**Status:** Completed
|
||||
|
||||
### Background
|
||||
|
||||
The current Calendar Workspace renders a full-page empty state when no Calendar events are returned.
|
||||
|
||||
Current behavior:
|
||||
|
||||
```text
|
||||
No scheduled work in this period
|
||||
|
||||
Try changing filters or create an Activity from the owning CRM record.
|
||||
```
|
||||
|
||||
This behavior is acceptable for an Agenda (list) view but is not appropriate for graphical calendar views.
|
||||
|
||||
A calendar should remain a calendar even when there are no events.
|
||||
|
||||
Users still need to:
|
||||
|
||||
* understand the selected date range
|
||||
* navigate between dates
|
||||
* visualize available time slots
|
||||
* click an empty day or time slot
|
||||
* create a new Activity
|
||||
* understand that the schedule is empty rather than the Calendar failing
|
||||
|
||||
---
|
||||
|
||||
## Required UX Change
|
||||
|
||||
### Day View
|
||||
|
||||
Always render the complete Calendar grid.
|
||||
|
||||
When there are no events:
|
||||
|
||||
* render the timeline/grid normally
|
||||
* show an inline empty-state hint inside the calendar body
|
||||
* keep toolbar, navigation, and date controls visible
|
||||
* allow selecting a time slot
|
||||
* allow creating a new Activity
|
||||
|
||||
Do **NOT** replace the Calendar with a full-page empty screen.
|
||||
|
||||
---
|
||||
|
||||
### Week View
|
||||
|
||||
Always render the Week grid.
|
||||
|
||||
When there are no events:
|
||||
|
||||
* render all days
|
||||
* render working hours
|
||||
* render current time indicator where applicable
|
||||
* render an inline empty hint only
|
||||
* preserve drag target areas for future Activity creation
|
||||
|
||||
Do **NOT** hide the calendar grid.
|
||||
|
||||
---
|
||||
|
||||
### Month View
|
||||
|
||||
Always render the Month calendar.
|
||||
|
||||
When there are no events:
|
||||
|
||||
* render the entire month
|
||||
* render all weeks
|
||||
* render date cells
|
||||
* render navigation
|
||||
* optionally display a small "No events" message inside the month body
|
||||
|
||||
The Month view should always look like a usable calendar.
|
||||
|
||||
---
|
||||
|
||||
### Agenda View
|
||||
|
||||
Agenda is different.
|
||||
|
||||
Agenda is a list-based presentation.
|
||||
|
||||
When there are no scheduled items:
|
||||
|
||||
Show the existing empty state:
|
||||
|
||||
```text
|
||||
No scheduled work in this period.
|
||||
|
||||
Try changing filters or create an Activity.
|
||||
```
|
||||
|
||||
This behavior remains correct.
|
||||
|
||||
---
|
||||
|
||||
## Rendering Rules
|
||||
|
||||
Preferred rendering logic:
|
||||
|
||||
```text
|
||||
Day
|
||||
↓
|
||||
Always render Calendar Grid
|
||||
|
||||
Week
|
||||
↓
|
||||
Always render Calendar Grid
|
||||
|
||||
Month
|
||||
↓
|
||||
Always render Calendar Grid
|
||||
|
||||
Agenda
|
||||
↓
|
||||
If empty
|
||||
Show Empty State
|
||||
Else
|
||||
Show Agenda List
|
||||
```
|
||||
|
||||
The Calendar component should never disappear simply because there are zero events.
|
||||
|
||||
---
|
||||
|
||||
## Empty Calendar Overlay
|
||||
|
||||
Instead of replacing the Calendar component, render a lightweight overlay or inline message.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
──────────────────────────────
|
||||
|
||||
No scheduled work
|
||||
|
||||
Your schedule is free.
|
||||
|
||||
[+ Add Activity]
|
||||
|
||||
──────────────────────────────
|
||||
```
|
||||
|
||||
The overlay must not block:
|
||||
|
||||
* changing dates
|
||||
* changing view
|
||||
* selecting days
|
||||
* selecting time slots
|
||||
* opening filters
|
||||
|
||||
---
|
||||
|
||||
## Add Activity Entry Point
|
||||
|
||||
When the Calendar contains no events, provide a visible primary action:
|
||||
|
||||
```text
|
||||
+ Add Activity
|
||||
```
|
||||
|
||||
The button must reuse the existing Activity form and Activity API.
|
||||
|
||||
Do not introduce a Calendar-specific create flow.
|
||||
|
||||
---
|
||||
|
||||
## Calendar Interaction
|
||||
|
||||
Even when empty, users must still be able to:
|
||||
|
||||
* navigate month/week/day
|
||||
* click a date
|
||||
* click a time slot
|
||||
* switch views
|
||||
* change filters
|
||||
* search
|
||||
* create Activities
|
||||
|
||||
The Calendar should remain fully interactive.
|
||||
|
||||
---
|
||||
|
||||
## Big Calendar Integration
|
||||
|
||||
Because the project uses **lramos33/big-calendar** as the UI foundation:
|
||||
|
||||
* always render the Big Calendar component for Day, Week, and Month
|
||||
* pass an empty event array when appropriate
|
||||
* never short-circuit rendering based on `events.length === 0`
|
||||
* use the Calendar's native layout even when there are no events
|
||||
|
||||
Avoid patterns such as:
|
||||
|
||||
```tsx
|
||||
if (events.length === 0) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
```
|
||||
|
||||
Instead:
|
||||
|
||||
```tsx
|
||||
<BigCalendar events={events} />
|
||||
|
||||
{events.length === 0 && view === "agenda" && (
|
||||
<AgendaEmptyState />
|
||||
)}
|
||||
|
||||
{events.length === 0 && view !== "agenda" && (
|
||||
<CalendarEmptyOverlay />
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UX Principles
|
||||
|
||||
The Calendar is a navigation surface, not only an event list.
|
||||
|
||||
Users should always understand:
|
||||
|
||||
* where they are
|
||||
* which dates are visible
|
||||
* which time slots are available
|
||||
* where new Activities can be scheduled
|
||||
|
||||
An empty schedule should feel like available capacity—not like a missing screen.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* Day view always renders the calendar grid.
|
||||
* Week view always renders the calendar grid.
|
||||
* Month view always renders the calendar grid.
|
||||
* Agenda keeps the existing list-style empty state.
|
||||
* Empty Day/Week/Month views display a lightweight overlay instead of replacing the Calendar.
|
||||
* Calendar navigation remains available when no events exist.
|
||||
* Date and time slots remain clickable.
|
||||
* Users can create Activities directly from an empty calendar.
|
||||
* No full-page empty state is shown for Day, Week, or Month views.
|
||||
* The implementation remains compatible with `lramos33/big-calendar` and ALLA OS projection architecture.
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -95,16 +96,24 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
<AgendaView groupedEvents={groupedEvents} />
|
||||
</TabsContent>
|
||||
<TabsContent value="day">
|
||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
||||
<CompactCalendarGrid
|
||||
events={events}
|
||||
view="day"
|
||||
anchorDate={filters.startFrom}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="week">
|
||||
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
|
||||
<CompactCalendarGrid
|
||||
events={events}
|
||||
view="week"
|
||||
anchorDate={filters.startFrom}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="month">
|
||||
<CompactCalendarGrid
|
||||
events={events.slice(0, 42)}
|
||||
columns={7}
|
||||
month
|
||||
events={events}
|
||||
view="month"
|
||||
anchorDate={filters.startFrom}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -191,43 +200,153 @@ function AgendaView({
|
||||
|
||||
function CompactCalendarGrid({
|
||||
events,
|
||||
columns,
|
||||
month,
|
||||
view,
|
||||
anchorDate,
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
columns: number;
|
||||
month?: boolean;
|
||||
view: "day" | "week" | "month";
|
||||
anchorDate: string;
|
||||
}) {
|
||||
if (!events.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
const cells = buildCalendarGridCells({ events, view, anchorDate });
|
||||
const isMonth = view === "month";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
columns === 1 && "grid-cols-1",
|
||||
columns === 7 && "grid-cols-1 md:grid-cols-7",
|
||||
)}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2",
|
||||
month && "min-h-36",
|
||||
)}
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(event.start)}
|
||||
</p>
|
||||
<CalendarEventPill event={event} />
|
||||
</div>
|
||||
))}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
view === "day" && "grid-cols-1",
|
||||
view !== "day" && "grid-cols-1 md:grid-cols-7",
|
||||
)}
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
<button
|
||||
key={cell.id}
|
||||
type="button"
|
||||
aria-label={`Select calendar slot ${cell.label}`}
|
||||
className={cn(
|
||||
"border-border/70 bg-muted/20 hover:bg-muted/40 min-h-28 rounded-lg border p-2 text-left transition-colors",
|
||||
isMonth && "min-h-36",
|
||||
cell.isToday && "ring-primary/30 ring-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">{cell.label}</p>
|
||||
{cell.subLabel ? (
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
{cell.subLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{cell.events.map((event) => (
|
||||
<CalendarEventPill key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!events.length ? <CalendarEmptyOverlay view={view} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarGridCell {
|
||||
id: string;
|
||||
label: string;
|
||||
subLabel: string | null;
|
||||
isToday: boolean;
|
||||
events: BigCalendarEvent[];
|
||||
}
|
||||
|
||||
function buildCalendarGridCells({
|
||||
events,
|
||||
view,
|
||||
anchorDate,
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
view: "day" | "week" | "month";
|
||||
anchorDate: string;
|
||||
}): CalendarGridCell[] {
|
||||
if (view === "day") {
|
||||
const day = startOfUtcDay(anchorDate);
|
||||
return Array.from({ length: 12 }, (_, index) => {
|
||||
const slot = new Date(day);
|
||||
slot.setUTCHours(8 + index, 0, 0, 0);
|
||||
return {
|
||||
id: `day-${slot.toISOString()}`,
|
||||
label: formatTime(slot.toISOString()),
|
||||
subLabel: index === 0 ? formatDate(slot.toISOString()) : null,
|
||||
isToday: isSameUtcDate(slot.toISOString(), new Date().toISOString()),
|
||||
events: events.filter((event) => isSameHour(event.start, slot.toISOString())),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const start = view === "week" ? startOfUtcDay(anchorDate) : startOfMonthGrid(anchorDate);
|
||||
const length = view === "week" ? 7 : 42;
|
||||
|
||||
return Array.from({ length }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setUTCDate(start.getUTCDate() + index);
|
||||
const iso = date.toISOString();
|
||||
return {
|
||||
id: `${view}-${iso}`,
|
||||
label: formatDate(iso),
|
||||
subLabel: view === "month" ? null : "Working day",
|
||||
isToday: isSameUtcDate(iso, new Date().toISOString()),
|
||||
events: events.filter((event) => isSameUtcDate(event.start, iso)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function CalendarEmptyOverlay({ view }: { view: "day" | "week" | "month" }) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-4 top-1/2 -translate-y-1/2">
|
||||
<div className="border-border/70 bg-background/95 mx-auto max-w-md rounded-xl border border-dashed p-4 text-center shadow-sm backdrop-blur">
|
||||
<Icons.calendar className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||
<p className="font-medium">No scheduled work. This {view} is free.</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
The calendar grid is still available for selecting dates and planning Activities.
|
||||
</p>
|
||||
<Button asChild className="pointer-events-auto mt-3" size="sm">
|
||||
<Link href="/dashboard/crm/activities">
|
||||
<Icons.add className="mr-1 h-4 w-4" />
|
||||
Add Activity
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startOfUtcDay(value: string): Date {
|
||||
const date = new Date(value);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function startOfMonthGrid(value: string): Date {
|
||||
const firstOfMonth = startOfUtcDay(value);
|
||||
firstOfMonth.setUTCDate(1);
|
||||
const gridStart = new Date(firstOfMonth);
|
||||
gridStart.setUTCDate(firstOfMonth.getUTCDate() - firstOfMonth.getUTCDay());
|
||||
return gridStart;
|
||||
}
|
||||
|
||||
function isSameUtcDate(left: string, right: string): boolean {
|
||||
return left.slice(0, 10) === right.slice(0, 10);
|
||||
}
|
||||
|
||||
function isSameHour(left: string, right: string): boolean {
|
||||
const leftDate = new Date(left);
|
||||
const rightDate = new Date(right);
|
||||
return (
|
||||
isSameUtcDate(leftDate.toISOString(), rightDate.toISOString()) &&
|
||||
leftDate.getUTCHours() === rightDate.getUTCHours()
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<article className="border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors">
|
||||
|
||||
Reference in New Issue
Block a user