feat: add daily view to calendar for mobile

- Add day view support in PostController with day parameter
- Implement day view UI with posts list, time display, and navigation
- Force day view on mobile devices (< 1024px)
- Add day/week/month view selector with all three options
- Use date.formatTime() from @/date.ts for consistent time formatting
- Clean up unused translations (add_post, no_posts_day)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paulo Castellano 2026-01-26 18:03:35 -03:00
parent 06b7d2928c
commit 93c245989a
6 changed files with 155 additions and 21 deletions

View file

@ -62,6 +62,11 @@ public function calendar(Request $request): Response|RedirectResponse
$tz = $workspace->timezone;
$view = $request->input('view', 'week');
// Day view
$currentDay = $request->input('day')
? Carbon::parse($request->input('day'), $tz)->startOfDay()
: Carbon::now($tz)->startOfDay();
// Week view
$weekStart = $request->input('week')
? Carbon::parse($request->input('week'), $tz)->startOfWeek()
@ -75,9 +80,17 @@ public function calendar(Request $request): Response|RedirectResponse
$monthStart = $monthDate->copy()->startOfMonth()->startOfWeek();
$monthEnd = $monthDate->copy()->endOfMonth()->endOfWeek();
// Get posts for both views (we query the larger range to cover both)
$rangeStart = $view === 'month' ? $monthStart : $weekStart;
$rangeEnd = $view === 'month' ? $monthEnd : $weekEnd;
// Get posts based on view
$rangeStart = match ($view) {
'day' => $currentDay,
'month' => $monthStart,
default => $weekStart,
};
$rangeEnd = match ($view) {
'day' => $currentDay->copy()->endOfDay(),
'month' => $monthEnd,
default => $weekEnd,
};
$posts = $workspace->posts()
->with(['postPlatforms' => function ($query) {
@ -91,6 +104,7 @@ public function calendar(Request $request): Response|RedirectResponse
return Inertia::render('posts/Calendar', [
'workspace' => $workspace,
'posts' => $posts,
'currentDay' => $currentDay->format('Y-m-d'),
'currentWeekStart' => $weekStart->format('Y-m-d'),
'currentMonth' => $monthDate->format('Y-m-d'),
'view' => $view,

View file

@ -3,6 +3,7 @@
return [
'title' => 'Calendar',
'today' => 'Today',
'day' => 'Day',
'week' => 'Week',
'month' => 'Month',
'new_post' => 'New Post',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,7 @@
return [
'title' => 'Calendário',
'today' => 'Hoje',
'day' => 'Dia',
'week' => 'Semana',
'month' => 'Mês',
'new_post' => 'Novo Post',

View file

@ -2,10 +2,12 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { IconChevronLeft, IconChevronRight, IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import DatePicker from '@/components/DatePicker.vue';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import date from '@/date';
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes';
@ -40,13 +42,34 @@ interface Workspace {
interface Props {
workspace: Workspace;
posts: Record<string, Post[]>;
currentDay: string;
currentWeekStart: string;
currentMonth: string;
view: 'week' | 'month';
view: 'day' | 'week' | 'month';
}
const props = defineProps<Props>();
// Mobile detection
const isMobile = ref(false);
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024;
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// Effective view (force day view on mobile)
const effectiveView = computed(() => {
return isMobile.value ? 'day' : props.view;
});
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('calendar.title'), href: calendar.url() },
]);
@ -61,6 +84,21 @@ const weekdayNames = computed(() => {
return names;
});
// Day view computed
const currentDay = computed(() => dayjs(props.currentDay));
const dayHeaderTitle = computed(() => {
return currentDay.value.format('dddd, D [de] MMMM [de] YYYY');
});
const dayPosts = computed(() => {
const dateKey = currentDay.value.format('YYYY-MM-DD');
return props.posts[dateKey] || [];
});
// Date picker model for day navigation
const selectedDate = ref(props.currentDay);
// Week view computed
const weekStart = computed(() => dayjs(props.currentWeekStart));
@ -113,7 +151,9 @@ const calendarWeeks = computed(() => {
// Header title based on view
const headerTitle = computed(() => {
return props.view === 'month' ? monthHeaderTitle.value : weekHeaderTitle.value;
if (effectiveView.value === 'day') return dayHeaderTitle.value;
if (effectiveView.value === 'month') return monthHeaderTitle.value;
return weekHeaderTitle.value;
});
const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
@ -121,6 +161,13 @@ const getPostsForDay = (day: dayjs.Dayjs): Post[] => {
return props.posts[dateKey] || [];
};
const navigateDay = (direction: number) => {
const newDay = currentDay.value.add(direction, 'day');
router.get(calendar.url({ query: { view: 'day', day: newDay.format('YYYY-MM-DD') } }), {}, {
preserveState: true,
});
};
const navigateWeek = (direction: number) => {
const newStart = weekStart.value.add(direction * 7, 'day');
router.get(calendar.url({ query: { view: 'week', week: newStart.format('YYYY-MM-DD') } }), {}, {
@ -136,7 +183,9 @@ const navigateMonth = (direction: number) => {
};
const navigate = (direction: number) => {
if (props.view === 'month') {
if (effectiveView.value === 'day') {
navigateDay(direction);
} else if (effectiveView.value === 'month') {
navigateMonth(direction);
} else {
navigateWeek(direction);
@ -144,7 +193,14 @@ const navigate = (direction: number) => {
};
const goToToday = () => {
router.get(calendar.url({ query: { view: props.view } }), {}, {
router.get(calendar.url({ query: { view: effectiveView.value } }), {}, {
preserveState: true,
});
};
const goToDate = (dateStr: string) => {
if (!dateStr) return;
router.get(calendar.url({ query: { view: 'day', day: dateStr } }), {}, {
preserveState: true,
});
};
@ -188,7 +244,6 @@ const getPlatformLogo = (platform: string): string => {
'pinterest': '/images/accounts/pinterest.png',
'bluesky': '/images/accounts/bluesky.png',
'mastodon': '/images/accounts/mastodon.png',
};
return logos[platform];
};
@ -198,7 +253,7 @@ const getPostUrl = (post: Post): string => {
};
const formatTime = (scheduledAt: string): string => {
return dayjs.utc(scheduledAt).tz(props.workspace.timezone).format('h:mm A');
return date.formatTime(scheduledAt) || '';
};
</script>
@ -209,8 +264,8 @@ const formatTime = (scheduledAt: string): string => {
<AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center gap-4">
<div class="flex items-center justify-between p-4 border-b gap-2">
<div class="flex items-center gap-2 lg:gap-4">
<div class="flex items-center gap-1">
<Button variant="outline" size="icon" @click="navigate(-1)">
<IconChevronLeft class="h-4 w-4" />
@ -222,27 +277,90 @@ const formatTime = (scheduledAt: string): string => {
<Button variant="outline" size="sm" @click="goToToday">
{{ $t('calendar.today') }}
</Button>
<h1 class="text-lg font-semibold">
<!-- Date Picker for mobile day view -->
<DatePicker v-if="isMobile" v-model="selectedDate" @update:model-value="goToDate" />
<!-- Title for desktop -->
<h1 class="hidden lg:block text-lg font-semibold">
{{ headerTitle }}
</h1>
</div>
<div class="flex items-center gap-4">
<Tabs :default-value="view" @update:model-value="switchView">
<div class="flex items-center gap-2 lg:gap-4">
<!-- View Tabs (hidden on mobile) -->
<Tabs v-if="!isMobile" :default-value="view" @update:model-value="switchView">
<TabsList>
<TabsTrigger value="day">{{ $t('calendar.day') }}</TabsTrigger>
<TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
<TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
</TabsList>
</Tabs>
<Link :href="storePost.url()" method="post">
<Button>
{{ $t('calendar.new_post') }}
<Button size="sm" class="lg:size-default">
<IconPlus class="h-4 w-4 lg:mr-2" />
<span class="hidden lg:inline">{{ $t('calendar.new_post') }}</span>
</Button>
</Link>
</div>
</div>
<!-- Day View (mobile or when view=day) -->
<div v-if="effectiveView === 'day'" class="flex-1 overflow-y-auto">
<!-- Mobile Header Title -->
<div class="lg:hidden px-4 py-3 border-b bg-muted/30">
<h2 class="text-base font-semibold text-center">
{{ dayHeaderTitle }}
</h2>
</div>
<div class="p-4 space-y-3">
<!-- Add Post Button -->
<Link :href="storePost.url({ query: { date: currentDay.format('YYYY-MM-DD') } })" method="post"
class="flex items-center justify-center gap-2 p-4 rounded-lg border border-dashed border-muted-foreground/30 text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-colors">
<IconPlus class="h-5 w-5" />
<span>{{ $t('calendar.new_post') }}</span>
</Link>
<!-- Posts List -->
<div v-if="dayPosts.length > 0" class="space-y-3">
<Link v-for="post in dayPosts" :key="post.id" :href="getPostUrl(post)" class="block">
<div class="p-4 rounded-lg border transition-all hover:ring-2 hover:ring-primary hover:ring-offset-1"
:class="getStatusColor(post.status)">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<!-- Time -->
<div class="text-sm font-semibold mb-2">
{{ formatTime(post.scheduled_at) }}
</div>
<!-- Platforms -->
<div class="flex -space-x-1 mb-2">
<img v-for="pp in post.post_platforms.slice(0, 5)" :key="pp.id"
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
class="h-6 w-6 rounded-full ring-2 ring-background" />
<span v-if="post.post_platforms.length > 5"
class="flex items-center justify-center h-6 w-6 rounded-full bg-muted text-xs font-medium ring-2 ring-background">
+{{ post.post_platforms.length - 5 }}
</span>
</div>
<!-- Content Preview -->
<p class="text-sm line-clamp-2 opacity-80">
{{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
</p>
</div>
</div>
</div>
</Link>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-muted-foreground">
<p>{{ $t('calendar.no_content') }}</p>
</div>
</div>
</div>
<!-- Week View -->
<div v-if="view === 'week'" class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
<div v-else-if="effectiveView === 'week'" class="flex-1 grid grid-cols-7 divide-x overflow-hidden">
<div v-for="day in weekDays" :key="day.format('YYYY-MM-DD')" class="flex flex-col min-h-0"
:class="{ 'bg-primary/5': isToday(day) }">
<!-- Day Header -->
@ -361,4 +479,4 @@ const formatTime = (scheduledAt: string): string => {
</div>
</div>
</AppLayout>
</template>
</template>