task ep1.6.1

This commit is contained in:
phaichayon
2026-07-13 17:01:54 +07:00
parent e0fcb3992b
commit 7d7548ebd4
3 changed files with 439 additions and 33 deletions

View File

@@ -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
View 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.

View File

@@ -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">