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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -95,16 +96,24 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
|||||||
<AgendaView groupedEvents={groupedEvents} />
|
<AgendaView groupedEvents={groupedEvents} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="day">
|
<TabsContent value="day">
|
||||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
<CompactCalendarGrid
|
||||||
|
events={events}
|
||||||
|
view="day"
|
||||||
|
anchorDate={filters.startFrom}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="week">
|
<TabsContent value="week">
|
||||||
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
|
<CompactCalendarGrid
|
||||||
|
events={events}
|
||||||
|
view="week"
|
||||||
|
anchorDate={filters.startFrom}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="month">
|
<TabsContent value="month">
|
||||||
<CompactCalendarGrid
|
<CompactCalendarGrid
|
||||||
events={events.slice(0, 42)}
|
events={events}
|
||||||
columns={7}
|
view="month"
|
||||||
month
|
anchorDate={filters.startFrom}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -191,40 +200,150 @@ function AgendaView({
|
|||||||
|
|
||||||
function CompactCalendarGrid({
|
function CompactCalendarGrid({
|
||||||
events,
|
events,
|
||||||
columns,
|
view,
|
||||||
month,
|
anchorDate,
|
||||||
}: {
|
}: {
|
||||||
events: BigCalendarEvent[];
|
events: BigCalendarEvent[];
|
||||||
columns: number;
|
view: "day" | "week" | "month";
|
||||||
month?: boolean;
|
anchorDate: string;
|
||||||
}) {
|
}) {
|
||||||
if (!events.length) {
|
const cells = buildCalendarGridCells({ events, view, anchorDate });
|
||||||
return <CalendarEmptyState />;
|
const isMonth = view === "month";
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-3",
|
"grid gap-3",
|
||||||
columns === 1 && "grid-cols-1",
|
view === "day" && "grid-cols-1",
|
||||||
columns === 7 && "grid-cols-1 md:grid-cols-7",
|
view !== "day" && "grid-cols-1 md:grid-cols-7",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{events.map((event) => (
|
{cells.map((cell) => (
|
||||||
<div
|
<button
|
||||||
key={event.id}
|
key={cell.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Select calendar slot ${cell.label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2",
|
"border-border/70 bg-muted/20 hover:bg-muted/40 min-h-28 rounded-lg border p-2 text-left transition-colors",
|
||||||
month && "min-h-36",
|
isMonth && "min-h-36",
|
||||||
|
cell.isToday && "ring-primary/30 ring-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-muted-foreground text-xs">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{formatDate(event.start)}
|
<p className="text-muted-foreground text-xs">{cell.label}</p>
|
||||||
</p>
|
{cell.subLabel ? (
|
||||||
<CalendarEventPill event={event} />
|
<span className="text-muted-foreground text-[11px]">
|
||||||
|
{cell.subLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{cell.events.map((event) => (
|
||||||
|
<CalendarEventPill key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user