Compare commits
No commits in common. "feb-2026-event-improvements" and "master" have entirely different histories.
feb-2026-e
...
master
@ -20,9 +20,9 @@ class PasswordController extends Controller
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->forceFill([
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
])->save();
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
@ -35,11 +35,11 @@ class RegisteredUserController extends Controller
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = new User();
|
||||
$user->name = $request->name;
|
||||
$user->email = $request->email;
|
||||
$user->password = $request->password;
|
||||
$user->save();
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
|
||||
@ -4,16 +4,21 @@ namespace App\Http\Controllers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\CalendarMeta;
|
||||
use App\Models\CalendarInstance;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMeta;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Calendar\CreateCalendar;
|
||||
use App\Services\Calendar\CalendarRangeResolver;
|
||||
use App\Services\Calendar\CalendarViewBuilder;
|
||||
use App\Services\Calendar\CalendarSettingsPersister;
|
||||
use App\Services\Event\EventRecurrence;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
private const VIEWS = ['day', 'week', 'month', 'four'];
|
||||
|
||||
/**
|
||||
* Consolidated calendar dashboard.
|
||||
*
|
||||
@ -27,13 +32,7 @@ class CalendarController extends Controller
|
||||
* ├─ calendars keyed by calendar id (for the left-hand toggle list)
|
||||
* └─ events flat list of VEVENTs in that range
|
||||
*/
|
||||
public function index(
|
||||
Request $request,
|
||||
EventRecurrence $recurrence,
|
||||
CalendarRangeResolver $rangeResolver,
|
||||
CalendarViewBuilder $viewBuilder,
|
||||
CalendarSettingsPersister $settingsPersister
|
||||
)
|
||||
public function index(Request $request)
|
||||
{
|
||||
/**
|
||||
*
|
||||
@ -49,36 +48,36 @@ class CalendarController extends Controller
|
||||
$timeFormat = $user->getSetting('app.time_format', '12');
|
||||
|
||||
// settings
|
||||
$defaults = $settingsPersister->defaults($user, $tz);
|
||||
$weekStart = $defaults['week_start'];
|
||||
$weekEnd = $defaults['week_end'];
|
||||
$defaultView = $user->getSetting('calendar.last_view', 'month');
|
||||
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
|
||||
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
|
||||
|
||||
// week start preference
|
||||
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday'
|
||||
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
|
||||
? $weekStartPref
|
||||
: 'sunday';
|
||||
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
|
||||
$weekEnd = (int) (($weekStart + 6) % 7);
|
||||
|
||||
// get the view and time range
|
||||
[$view, $range] = $rangeResolver->resolveRange(
|
||||
$request,
|
||||
$tz,
|
||||
$weekStart,
|
||||
$weekEnd,
|
||||
$defaults['view'],
|
||||
$defaults['date']
|
||||
);
|
||||
[$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
$density = $settingsPersister->resolveDensity($request, $defaults['density']);
|
||||
$stepMinutes = $density['step'];
|
||||
$labelEvery = $density['label_every'];
|
||||
|
||||
$daytimeHoursEnabled = $settingsPersister->resolveDaytimeHours($request, $defaults['daytime_hours']);
|
||||
$daytimeHoursRange = $settingsPersister->daytimeHoursRange();
|
||||
$daytimeHoursRows = $daytimeHoursEnabled
|
||||
? intdiv((($daytimeHoursRange['end'] - $daytimeHoursRange['start']) * 60), 15)
|
||||
: 96;
|
||||
$daytimeHoursForView = ($daytimeHoursEnabled && in_array($view, ['day', 'week', 'four'], true))
|
||||
? $daytimeHoursRange
|
||||
: null;
|
||||
// get the display density, if present (in minutes for each step)
|
||||
$stepMinutes = (int) $request->query('density', $defaultDensity);
|
||||
if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down
|
||||
$stepMinutes = 30;
|
||||
}
|
||||
$labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row
|
||||
15 => 1,
|
||||
30 => 2,
|
||||
60 => 4,
|
||||
};
|
||||
|
||||
// date range span and controls
|
||||
$span = $rangeResolver->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||
$nav = $rangeResolver->navDates($view, $range['start'], $tz);
|
||||
$span = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||
$nav = $this->navDates($view, $range['start'], $tz);
|
||||
|
||||
// get the user's visible calendars from the left bar
|
||||
$visible = collect($request->query('c', []));
|
||||
@ -87,14 +86,11 @@ class CalendarController extends Controller
|
||||
$anchorDate = $request->query('date', now($tz)->toDateString());
|
||||
|
||||
// persist settings
|
||||
$settingsPersister->persist(
|
||||
$user,
|
||||
$request,
|
||||
$view,
|
||||
$range['start'],
|
||||
$stepMinutes,
|
||||
$daytimeHoursEnabled
|
||||
);
|
||||
if ($request->hasAny(['view', 'date', 'density'])) {
|
||||
$user->setSetting('calendar.last_view', $view);
|
||||
$user->setSetting('calendar.last_date', $range['start']->toDateString());
|
||||
$user->setSetting('calendar.last_density', (string) $stepMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
@ -122,20 +118,78 @@ class CalendarController extends Controller
|
||||
$calendars->pluck('id'),
|
||||
$span['start'],
|
||||
$span['end']
|
||||
);
|
||||
)->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) {
|
||||
|
||||
// build event payload
|
||||
$events = $viewBuilder->buildEventPayloads(
|
||||
$events,
|
||||
$calendar_map,
|
||||
$timeFormat,
|
||||
$view,
|
||||
$range,
|
||||
$tz,
|
||||
$recurrence,
|
||||
$span,
|
||||
$daytimeHoursForView,
|
||||
);
|
||||
// event's calendar
|
||||
$cal = $calendar_map[$e->calendarid];
|
||||
|
||||
// get utc dates from the database
|
||||
$start_utc = $e->meta->start_at ??
|
||||
Carbon::createFromTimestamp($e->firstoccurence);
|
||||
$end_utc = $e->meta->end_at ??
|
||||
($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
|
||||
|
||||
// time format handling
|
||||
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
||||
|
||||
// convert to calendar timezone
|
||||
$timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone');
|
||||
$start_local = $start_utc->copy()->timezone($timezone);
|
||||
$end_local = optional($end_utc)->copy()->timezone($timezone);
|
||||
|
||||
// convert utc to user tz for grid placement (columns/rows must match view headers)
|
||||
$start_for_grid = $start_utc->copy()->tz($tz);
|
||||
$end_for_grid = optional($end_utc)->copy()->tz($tz);
|
||||
|
||||
// placement for time-based layouts
|
||||
$placement = $this->slotPlacement(
|
||||
$start_for_grid,
|
||||
$end_for_grid,
|
||||
$range['start']->copy()->tz($tz),
|
||||
$view,
|
||||
15
|
||||
);
|
||||
|
||||
// color handling
|
||||
$color = $cal['meta_color']
|
||||
?? $cal['calendarcolor']
|
||||
?? default_calendar_color();
|
||||
$colorFg = $cal['meta_color_fg']
|
||||
?? contrast_text_color($color);
|
||||
|
||||
logger()->info('event times', [
|
||||
'id' => $e->id,
|
||||
'start_at' => optional($e->meta)->start_at,
|
||||
'end_at' => optional($e->meta)->end_at,
|
||||
'firstoccurence' => $e->firstoccurence,
|
||||
'lastoccurence' => $e->lastoccurence,
|
||||
]);
|
||||
|
||||
// return events array
|
||||
return [
|
||||
// core data
|
||||
'id' => $e->id,
|
||||
'calendar_id' => $e->calendarid,
|
||||
'calendar_slug' => $cal->slug,
|
||||
'title' => $e->meta->title ?? 'No title',
|
||||
'description' => $e->meta->description ?? 'No description.',
|
||||
'start' => $start_utc->toIso8601String(),
|
||||
'end' => optional($end_utc)->toIso8601String(),
|
||||
'start_ui' => $start_local->format($uiFormat),
|
||||
'end_ui' => optional($end_local)->format($uiFormat),
|
||||
'timezone' => $timezone,
|
||||
'visible' => $cal->visible,
|
||||
'color' => $color,
|
||||
'color_fg' => $colorFg,
|
||||
// slot placement for time-based grid
|
||||
'start_row' => $placement['start_row'],
|
||||
'end_row' => $placement['end_row'],
|
||||
'row_span' => $placement['row_span'],
|
||||
'start_col' => $placement['start_col'],
|
||||
'duration' => $placement['duration'],
|
||||
|
||||
];
|
||||
})->keyBy('id');
|
||||
|
||||
/**
|
||||
*
|
||||
@ -144,9 +198,10 @@ class CalendarController extends Controller
|
||||
|
||||
// create the mini calendar grid based on the mini cal controls
|
||||
$mini_anchor = $request->query('mini', $range['start']->toDateString());
|
||||
$mini_anchor_date = $rangeResolver->safeDate($mini_anchor, $tz, $range['start']->toDateString());
|
||||
|
||||
$mini_start = $mini_anchor_date->copy()->startOfMonth();
|
||||
// anchor is a DATE string, so create it explicitly in the user tz
|
||||
$mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz)
|
||||
->startOfMonth();
|
||||
|
||||
$mini_nav = [
|
||||
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
||||
@ -154,7 +209,7 @@ class CalendarController extends Controller
|
||||
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
||||
'label' => $mini_start->format('F Y'),
|
||||
];
|
||||
$mini_headers = $viewBuilder->weekdayHeaders($tz, $weekStart);
|
||||
$mini_headers = $this->weekdayHeaders($tz, $weekStart);
|
||||
|
||||
// compute the mini's 42-day span (Mon..Sun, 6 rows)
|
||||
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
||||
@ -168,22 +223,35 @@ class CalendarController extends Controller
|
||||
$calendars->pluck('id'),
|
||||
$mini_grid_start,
|
||||
$mini_grid_end
|
||||
);
|
||||
)->map(function ($e) use ($calendar_map, $tz) {
|
||||
$cal = $calendar_map[$e->calendarid];
|
||||
|
||||
$mini_events = $viewBuilder->buildEventPayloads(
|
||||
$mini_events,
|
||||
$calendar_map,
|
||||
$timeFormat,
|
||||
$view,
|
||||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||||
$tz,
|
||||
$recurrence,
|
||||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||||
null,
|
||||
);
|
||||
$start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence);
|
||||
$end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
|
||||
|
||||
$color = $cal->meta_color
|
||||
?? $cal->calendarcolor
|
||||
?? default_calendar_color();
|
||||
$colorFg = $cal->meta_color_fg
|
||||
?? contrast_text_color($color);
|
||||
|
||||
return [
|
||||
'id' => $e->id,
|
||||
'calendar_id' => $e->calendarid,
|
||||
'calendar_slug' => $cal->slug,
|
||||
'title' => $e->meta->title ?? 'No title',
|
||||
'description' => $e->meta->description ?? 'No description.',
|
||||
'start' => $start_utc->toIso8601String(),
|
||||
'end' => optional($end_utc)->toIso8601String(),
|
||||
'timezone' => $tz,
|
||||
'visible' => $cal->visible,
|
||||
'color' => $color,
|
||||
'color_fg' => $colorFg,
|
||||
];
|
||||
})->keyBy('id');
|
||||
|
||||
// now build the mini from mini_events (not from $events)
|
||||
$mini = $viewBuilder->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||||
$mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||||
|
||||
/**
|
||||
*
|
||||
@ -191,39 +259,27 @@ class CalendarController extends Controller
|
||||
*/
|
||||
|
||||
// create the calendar grid of days
|
||||
$grid = $viewBuilder->buildCalendarGrid($view, $range, $events, $tz, $span);
|
||||
$grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd);
|
||||
|
||||
// get the title
|
||||
$header = $rangeResolver->headerTitle($view, $range['start'], $range['end']);
|
||||
$header = $this->headerTitle($view, $range['start'], $range['end']);
|
||||
|
||||
// format the data for the frontend, including separate arrays for events specifically and the big grid
|
||||
$payload = [
|
||||
'view' => $view,
|
||||
'range' => $range,
|
||||
'nav' => $nav,
|
||||
'header' => $header,
|
||||
'week_start' => $weekStart,
|
||||
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
|
||||
'events' => $events, // keyed by occurrence
|
||||
'grid' => $grid, // day objects hold only ID-sets
|
||||
'mini' => $mini, // mini calendar days with events for indicators
|
||||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||||
'mini_headers' => $mini_headers,
|
||||
'active' => [
|
||||
'date' => $range['start']->toDateString(),
|
||||
'year' => $range['start']->format('Y'),
|
||||
'month' => $range['start']->format("F"),
|
||||
'day' => $range['start']->format("d"),
|
||||
'view' => $view,
|
||||
'range' => $range,
|
||||
'nav' => $nav,
|
||||
'header' => $header,
|
||||
'active' => [
|
||||
'date' => $range['start']->toDateString(),
|
||||
'year' => $range['start']->format('Y'),
|
||||
'month' => $range['start']->format("F"),
|
||||
'day' => $range['start']->format("d"),
|
||||
],
|
||||
'daytime_hours' => [
|
||||
'enabled' => $daytimeHoursEnabled,
|
||||
'start' => $daytimeHoursRange['start'],
|
||||
'end' => $daytimeHoursRange['end'],
|
||||
'rows' => $daytimeHoursRows,
|
||||
],
|
||||
'timezone' => $tz,
|
||||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||||
'week_start' => $weekStart,
|
||||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||||
{
|
||||
// compute colors
|
||||
$color = $cal->meta_color
|
||||
?? $cal->calendarcolor
|
||||
?? default_calendar_color();
|
||||
@ -242,29 +298,24 @@ class CalendarController extends Controller
|
||||
],
|
||||
];
|
||||
}),
|
||||
'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart),
|
||||
'events' => $events, // keyed, one copy each
|
||||
'grid' => $grid, // day objects hold only ID-sets
|
||||
'mini' => $mini, // mini calendar days with events for indicators
|
||||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||||
'mini_headers' => $mini_headers,
|
||||
];
|
||||
|
||||
// time-based payload values
|
||||
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
||||
if ($timeBased) {
|
||||
if ($timeBased)
|
||||
{
|
||||
// create the time gutter if we're in a time-based view
|
||||
$payload['slots'] = $viewBuilder->timeSlots(
|
||||
$range['start'],
|
||||
$tz,
|
||||
$timeFormat,
|
||||
$daytimeHoursEnabled ? $daytimeHoursRange : null
|
||||
);
|
||||
$payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat);
|
||||
$payload['time_format'] = $timeFormat; // optional, if the blade cares
|
||||
|
||||
// add the now indicator
|
||||
$payload['now'] = $viewBuilder->nowIndicator(
|
||||
$view,
|
||||
$range,
|
||||
$tz,
|
||||
15,
|
||||
1,
|
||||
$daytimeHoursEnabled ? $daytimeHoursRange : null
|
||||
);
|
||||
$payload['now'] = $this->nowIndicator($view, $range, $tz, 15);
|
||||
}
|
||||
|
||||
// send the density array always, even though it doesn't matter for month
|
||||
@ -401,4 +452,513 @@ class CalendarController extends Controller
|
||||
* Private helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* Prepare nav dates for the movement buttons in the calendar header
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* 'prev' => 2026-08-19
|
||||
* 'next' => 2026-08-21
|
||||
* 'today' => 2026-08-20
|
||||
* ]
|
||||
*/
|
||||
private function navDates(string $view, Carbon $start, string $tz): array
|
||||
{
|
||||
// always compute in the user tz so the UX is consistent
|
||||
$start = $start->copy()->tz($tz);
|
||||
|
||||
return match ($view)
|
||||
{
|
||||
'day' => [
|
||||
'prev' => $start->copy()->subDay()->toDateString(),
|
||||
'next' => $start->copy()->addDay()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'week' => [
|
||||
'prev' => $start->copy()->subWeek()->toDateString(),
|
||||
'next' => $start->copy()->addWeek()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'four' => [
|
||||
'prev' => $start->copy()->subDays(4)->toDateString(),
|
||||
'next' => $start->copy()->addDays(4)->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
default => [ // month
|
||||
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
|
||||
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Interpret $view and $date filters and normalize into a Carbon range
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* $view,
|
||||
* [
|
||||
* 'start' => Carbon,
|
||||
* 'end' => Carbon
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
private function resolveRange(
|
||||
Request $request,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd,
|
||||
string $defaultView,
|
||||
string $defaultDate
|
||||
): array {
|
||||
// get the view
|
||||
$requestView = $request->query('view', $defaultView);
|
||||
$view = in_array($requestView, self::VIEWS, true)
|
||||
? $requestView
|
||||
: 'month';
|
||||
$date = $request->query('date', $defaultDate);
|
||||
|
||||
// anchor date in the user's timezone
|
||||
$anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
|
||||
|
||||
// set dates based on view
|
||||
switch ($view)
|
||||
{
|
||||
case 'day':
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->endOfDay();
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start = $anchor->copy()->startOfWeek($weekStart);
|
||||
$end = $anchor->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
|
||||
case 'four':
|
||||
// a rolling 4-day "agenda" view starting at anchor
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->addDays(3)->endOfDay();
|
||||
break;
|
||||
|
||||
default: // month
|
||||
$start = $anchor->copy()->startOfMonth();
|
||||
$end = $anchor->copy()->endOfMonth();
|
||||
}
|
||||
|
||||
return [$view, ['start' => $start, 'end' => $end]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the grid for each view with pre- and post-padding if needed
|
||||
*
|
||||
* This is different from resolveRange() as the rendered span != logical range
|
||||
*
|
||||
* Month: start of the month's first week to end of the last week
|
||||
* Week: Sunday to Saturday
|
||||
* Four: selected day to 3 days later
|
||||
* Day: start of the day to the end of the day
|
||||
*/
|
||||
private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
|
||||
{
|
||||
switch ($view)
|
||||
{
|
||||
case 'day':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->endOfDay();
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start = $range['start']->copy()->startOfWeek($weekStart);
|
||||
$end = $range['start']->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
|
||||
case 'four':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->addDays(3);
|
||||
break;
|
||||
|
||||
default: // month
|
||||
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
|
||||
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||||
}
|
||||
|
||||
return ['start' => $start, 'end' => $end];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Prepare the calendar title based on the view
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* 'strong' => 'August 20',
|
||||
* 'span' => '2026',
|
||||
* ]
|
||||
*/
|
||||
private function headerTitle(string $view, Carbon $start, Carbon $end): array
|
||||
{
|
||||
$sameDay = $start->isSameDay($end);
|
||||
$sameMonth = $start->isSameMonth($end);
|
||||
$sameYear = $start->isSameYear($end);
|
||||
|
||||
// month default
|
||||
$strong = $start->format('F');
|
||||
$span = $start->format('Y');
|
||||
|
||||
if ($view === 'day' || $sameDay) {
|
||||
return [
|
||||
'strong' => $start->format('F j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($view, ['week', 'four'], true)) {
|
||||
if ($sameMonth && $sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F') . ' to ' . $end->format('F'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
|
||||
'span' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['strong' => $strong, 'span' => $span];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the time gutter for time-based views
|
||||
*/
|
||||
private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array
|
||||
{
|
||||
$minutesPerSlot = 15;
|
||||
$slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96
|
||||
|
||||
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
|
||||
|
||||
$slots = [];
|
||||
$t = $dayStart->copy()->tz($tz)->startOfDay();
|
||||
|
||||
for ($i = 0; $i < $slotsPerDay; $i++) {
|
||||
$slots[] = [
|
||||
'iso' => $t->toIso8601String(),
|
||||
'label' => $t->format($format),
|
||||
'key' => $t->format('H:i'), // stable "machine" value
|
||||
'index' => $i, // 0..95
|
||||
'minutes' => $i * $minutesPerSlot,
|
||||
'duration' => $minutesPerSlot, // handy for styling math
|
||||
];
|
||||
|
||||
$t->addMinutes($minutesPerSlot);
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Time-based layout slot placement
|
||||
*
|
||||
* Placements object:
|
||||
* [
|
||||
* 'start_line' => '24',
|
||||
* 'end_line' => '32',
|
||||
* 'span' => '4',
|
||||
* 'duration' => '60',
|
||||
* ]
|
||||
**/
|
||||
private function slotPlacement(
|
||||
Carbon $startLocal,
|
||||
?Carbon $endLocal,
|
||||
Carbon $rangeStart,
|
||||
string $view,
|
||||
int $minutesPerSlot = 15
|
||||
): array
|
||||
{
|
||||
$start = $startLocal->copy();
|
||||
$end = ($endLocal ?? $startLocal)->copy();
|
||||
|
||||
// get the real duration in minutes
|
||||
$durationMinutes = max(0, $start->diffInMinutes($end, false));
|
||||
|
||||
// duration for display purposes
|
||||
$displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot;
|
||||
|
||||
// row placement (96 rows when minutesPerSlot=15)
|
||||
$startMinutesFromMidnight = ($start->hour * 60) + $start->minute;
|
||||
$startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1;
|
||||
|
||||
$rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot));
|
||||
$endRow = $startRow + $rowSpan;
|
||||
|
||||
// column placement
|
||||
$maxCols = match ($view) {
|
||||
'day' => 1,
|
||||
'four' => 4,
|
||||
'week' => 7,
|
||||
default => 1, // month won’t use this
|
||||
};
|
||||
// rangeStart is already the "first day column" for week/four/day
|
||||
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
|
||||
|
||||
// clamp to view columns
|
||||
$startCol = max(1, min($maxCols, $startCol));
|
||||
|
||||
return [
|
||||
'start_row' => $startRow,
|
||||
'end_row' => $endRow,
|
||||
'row_span' => $rowSpan,
|
||||
'duration' => $durationMinutes,
|
||||
'start_col' => $startCol,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Assemble an array of day-objects for the requested view.
|
||||
*
|
||||
* Day object shape:
|
||||
* [
|
||||
* 'date' => '2025-07-14',
|
||||
* 'label' => '14', // two-digit day number
|
||||
* 'in_month' => true|false, // helpful for grey-out styling
|
||||
* 'events' => [ …event payloads… ]
|
||||
* ]
|
||||
*
|
||||
* For the "month" view the return value also contains
|
||||
* 'weeks' => [ [7 day-objs], [7 day-objs], … ]
|
||||
*/
|
||||
private function buildCalendarGrid(
|
||||
string $view,
|
||||
array $range,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd): array
|
||||
{
|
||||
// use the same span the events were fetched for (month padded to full weeks, etc.)
|
||||
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||
|
||||
// today checks
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// index events by YYYY-MM-DD for quick lookup
|
||||
$events_by_day = [];
|
||||
foreach ($events as $ev)
|
||||
{
|
||||
$evTz = $ev['timezone'] ?? $tz;
|
||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
||||
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
|
||||
|
||||
for ($d = $start->copy()->startOfDay();
|
||||
$d->lte($end->copy()->endOfDay());
|
||||
$d->addDay()) {
|
||||
|
||||
$key = $d->toDateString();
|
||||
$events_by_day[$key][] = $ev['id'];
|
||||
}
|
||||
}
|
||||
|
||||
// view span bounds and build day objects
|
||||
$days = [];
|
||||
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
|
||||
$iso = $day->toDateString();
|
||||
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'label' => $day->format('j'),
|
||||
'in_month' => $day->month === $range['start']->month,
|
||||
'is_today' => $day->isSameDay($today),
|
||||
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
|
||||
];
|
||||
}
|
||||
|
||||
return $view === 'month'
|
||||
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
|
||||
: ['days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the specific view's date headers
|
||||
*
|
||||
* For day/week/four, this is an array of number dates and their day name,
|
||||
* for month it's the user's preferred weekdays
|
||||
*/
|
||||
private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
|
||||
{
|
||||
$start = $range['start']->copy()->tz($tz);
|
||||
$end = $range['end']->copy()->tz($tz);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// month view: weekday headers (Sunday..Saturday for now)
|
||||
if ($view === 'month') {
|
||||
return collect($this->weekdayHeaders($tz, $weekStart))
|
||||
->map(fn ($h) => $h + ['is_today' => false])
|
||||
->all();
|
||||
}
|
||||
|
||||
// day/week/four: column headers for each day in range
|
||||
$headers = [];
|
||||
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
|
||||
$date = $d->toDateString();
|
||||
$headers[] = [
|
||||
'date' => $d->toDateString(), // 2026-01-31
|
||||
'day' => $d->format('j'), // 31
|
||||
'dow' => $d->translatedFormat('l'), // Saturday (localized)
|
||||
'dow_short' => $d->translatedFormat('D'), // Sat (localized)
|
||||
'month' => $d->translatedFormat('M'), // Jan (localized)
|
||||
'is_today' => $date === $today, // flag for viewing real today
|
||||
];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Specific headers for month views (full and mini)
|
||||
*/
|
||||
private function weekdayHeaders(string $tz, int $weekStart): array
|
||||
{
|
||||
$headers = [];
|
||||
$d = Carbon::now($tz)->startOfWeek($weekStart);
|
||||
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$headers[] = [
|
||||
// stable key (0..6 from the start day)
|
||||
'key' => $i,
|
||||
// Sun, Mon...
|
||||
'label' => $d->translatedFormat('D'),
|
||||
];
|
||||
$d->addDay();
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Build the mini-month grid for day buttons in the bottom left of the UI
|
||||
*
|
||||
* Returns ['days' => [
|
||||
* [
|
||||
* 'date' => '2025-06-30',
|
||||
* 'label' => '30',
|
||||
* 'in_month' => false,
|
||||
* 'events' => [id, id …]
|
||||
* ], …
|
||||
* ]]
|
||||
*/
|
||||
private function buildMiniGrid(
|
||||
Carbon $monthStart,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd): array
|
||||
{
|
||||
$monthStart = $monthStart->copy()->tz($tz);
|
||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||
|
||||
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
|
||||
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
|
||||
|
||||
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
|
||||
$gridEnd->addWeek();
|
||||
}
|
||||
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees)
|
||||
$byDay = [];
|
||||
foreach ($events as $ev) {
|
||||
$s = Carbon::parse($ev['start'])->tz($tz);
|
||||
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
|
||||
|
||||
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
|
||||
$byDay[$d->toDateString()][] = $ev['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$days = [];
|
||||
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
|
||||
$iso = $d->toDateString();
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'label' => $d->format('j'),
|
||||
'in_month' => $d->between($monthStart, $monthEnd),
|
||||
'is_today' => $iso === $today,
|
||||
'events' => $byDay[$iso] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
return ['days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the "now" indicator on the grid if we're in a time-based view and on the current day
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'show' => bool,
|
||||
* 'row' => int, // 1..96 (15-min grid line)
|
||||
* 'col_start' => int, // grid column start
|
||||
* 'col_end' => int, // grid column end
|
||||
* ]
|
||||
*/
|
||||
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array
|
||||
{
|
||||
// only meaningful for time-based views
|
||||
if (!in_array($view, ['day', 'week', 'four'], true)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
$now = Carbon::now($tz);
|
||||
|
||||
$start = $range['start']->copy()->tz($tz)->startOfDay();
|
||||
$end = $range['end']->copy()->tz($tz)->endOfDay();
|
||||
|
||||
// show only if "now" is inside the visible range
|
||||
if (! $now->betweenIncluded($start, $end)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
// row: minutes since midnight, snapped down to slot size
|
||||
$minutes = ($now->hour * 60) + $now->minute;
|
||||
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot;
|
||||
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based
|
||||
|
||||
// column: 1..N where 1 is the first day column in the events grid
|
||||
if ($view === 'day') {
|
||||
$dayCol = 1;
|
||||
} else {
|
||||
// IMPORTANT: compare dates at midnight to avoid fractional diffs
|
||||
$todayStart = $now->copy()->startOfDay();
|
||||
$dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4
|
||||
}
|
||||
|
||||
$dayCol = (int) $dayCol;
|
||||
|
||||
return [
|
||||
'show' => true,
|
||||
'row' => (int) $row,
|
||||
'day_col' => $dayCol,
|
||||
'col_start' => $dayCol,
|
||||
'col_end' => $dayCol + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\Event;
|
||||
use App\Models\Location;
|
||||
use App\Services\Event\EventRecurrence;
|
||||
use App\Services\Location\Geocoder;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -13,11 +12,12 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Sabre\VObject\Reader;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* create a new event
|
||||
* create a new event page
|
||||
*/
|
||||
public function create(Calendar $calendar, Request $request)
|
||||
{
|
||||
@ -47,7 +47,6 @@ class EventController extends Controller
|
||||
|
||||
$start = $anchor->copy()->format('Y-m-d\TH:i');
|
||||
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
||||
$rrule = '';
|
||||
|
||||
return view('event.form', compact(
|
||||
'calendar',
|
||||
@ -56,14 +55,13 @@ class EventController extends Controller
|
||||
'start',
|
||||
'end',
|
||||
'tz',
|
||||
'rrule',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* edit event
|
||||
* edit event page
|
||||
*/
|
||||
public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence)
|
||||
public function edit(Calendar $calendar, Event $event, Request $request)
|
||||
{
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
@ -85,17 +83,13 @@ class EventController extends Controller
|
||||
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
|
||||
: null;
|
||||
|
||||
$rrule = $event->meta?->extra['rrule']
|
||||
?? $recurrence->extractRrule($event)
|
||||
?? '';
|
||||
|
||||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
|
||||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz'));
|
||||
}
|
||||
|
||||
/**
|
||||
* single event view handling
|
||||
*/
|
||||
public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence)
|
||||
public function show(Request $request, Calendar $calendar, Event $event)
|
||||
{
|
||||
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
@ -108,29 +102,16 @@ class EventController extends Controller
|
||||
$isHtmx = $request->header('HX-Request') === 'true';
|
||||
$tz = $this->displayTimezone($calendar, $request);
|
||||
|
||||
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
|
||||
$occurrenceParam = $request->query('occurrence');
|
||||
$occurrenceStart = null;
|
||||
if ($occurrenceParam) {
|
||||
try {
|
||||
$occurrenceStart = Carbon::parse($occurrenceParam)->utc();
|
||||
} catch (\Throwable $e) {
|
||||
$occurrenceStart = null;
|
||||
}
|
||||
}
|
||||
$occurrence = $occurrenceStart
|
||||
? $recurrence->resolveOccurrence($event, $occurrenceStart)
|
||||
: null;
|
||||
|
||||
$startUtc = $occurrence['start'] ?? ($event->meta?->start_at
|
||||
// prefer meta utc timestamps, fall back to sabre columns
|
||||
$startUtc = $event->meta?->start_at
|
||||
? Carbon::parse($event->meta->start_at)->utc()
|
||||
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'));
|
||||
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC');
|
||||
|
||||
$endUtc = $occurrence['end'] ?? ($event->meta?->end_at
|
||||
$endUtc = $event->meta?->end_at
|
||||
? Carbon::parse($event->meta->end_at)->utc()
|
||||
: ($event->lastoccurence
|
||||
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
|
||||
: $startUtc->copy()));
|
||||
: $startUtc->copy());
|
||||
|
||||
// convert for display
|
||||
$start = $startUtc->copy()->timezone($tz);
|
||||
@ -146,7 +127,7 @@ class EventController extends Controller
|
||||
/**
|
||||
* insert vevent into sabre’s calendarobjects + meta row
|
||||
*/
|
||||
public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse
|
||||
public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
@ -158,7 +139,6 @@ class EventController extends Controller
|
||||
'location' => ['nullable', 'string'],
|
||||
'all_day' => ['sometimes', 'boolean'],
|
||||
'category' => ['nullable', 'string', 'max:50'],
|
||||
'rrule' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
// normalized location hints (optional)
|
||||
'loc_display_name' => ['nullable', 'string'],
|
||||
@ -180,19 +160,25 @@ class EventController extends Controller
|
||||
|
||||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||||
|
||||
$rrule = $this->normalizeRrule($request);
|
||||
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
|
||||
$description = $this->escapeIcsText($data['description'] ?? '');
|
||||
$locationStr = $this->escapeIcsText($data['location'] ?? '');
|
||||
|
||||
$ical = $recurrence->buildCalendar([
|
||||
'uid' => $uid,
|
||||
'start_utc' => $startUtc,
|
||||
'end_utc' => $endUtc,
|
||||
'summary' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'location' => $data['location'] ?? '',
|
||||
'tzid' => $rrule ? $tz : null,
|
||||
'rrule' => $rrule,
|
||||
]);
|
||||
// write dtstart/dtend as utc with "Z" so we have one canonical representation
|
||||
$ical = <<<ICS
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:$uid
|
||||
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
|
||||
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
|
||||
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
|
||||
SUMMARY:{$this->escapeIcsText($data['title'])}
|
||||
DESCRIPTION:$description
|
||||
LOCATION:$locationStr
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
ICS;
|
||||
|
||||
$event = Event::create([
|
||||
'calendarid' => $calendar->id,
|
||||
@ -216,7 +202,6 @@ class EventController extends Controller
|
||||
'category' => $data['category'] ?? null,
|
||||
'start_at' => $startUtc,
|
||||
'end_at' => $endUtc,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
return redirect()->route('calendar.show', $calendar);
|
||||
@ -225,7 +210,7 @@ class EventController extends Controller
|
||||
/**
|
||||
* update vevent + meta
|
||||
*/
|
||||
public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse
|
||||
public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
@ -241,7 +226,6 @@ class EventController extends Controller
|
||||
'location' => ['nullable', 'string'],
|
||||
'all_day' => ['sometimes', 'boolean'],
|
||||
'category' => ['nullable', 'string', 'max:50'],
|
||||
'rrule' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$tz = $this->displayTimezone($calendar, $request);
|
||||
@ -251,23 +235,25 @@ class EventController extends Controller
|
||||
|
||||
$uid = $event->uid;
|
||||
|
||||
$rrule = $this->normalizeRrule($request);
|
||||
$extra = $event->meta?->extra ?? [];
|
||||
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
|
||||
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
|
||||
$description = $this->escapeIcsText($data['description'] ?? '');
|
||||
$locationStr = $this->escapeIcsText($data['location'] ?? '');
|
||||
$summary = $this->escapeIcsText($data['title']);
|
||||
|
||||
$ical = $recurrence->buildCalendar([
|
||||
'uid' => $uid,
|
||||
'start_utc' => $startUtc,
|
||||
'end_utc' => $endUtc,
|
||||
'summary' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'location' => $data['location'] ?? '',
|
||||
'tzid' => $rruleForIcs ? $tz : null,
|
||||
'rrule' => $rruleForIcs,
|
||||
'exdate' => $extra['exdate'] ?? [],
|
||||
'rdate' => $extra['rdate'] ?? [],
|
||||
]);
|
||||
$ical = <<<ICS
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:$uid
|
||||
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
|
||||
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
|
||||
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
|
||||
SUMMARY:$summary
|
||||
DESCRIPTION:$description
|
||||
LOCATION:$locationStr
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
ICS;
|
||||
|
||||
$event->update([
|
||||
'calendardata' => $ical,
|
||||
@ -283,7 +269,6 @@ class EventController extends Controller
|
||||
'category' => $data['category'] ?? null,
|
||||
'start_at' => $startUtc,
|
||||
'end_at' => $endUtc,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
return redirect()->route('calendar.show', $calendar);
|
||||
@ -320,55 +305,6 @@ class EventController extends Controller
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function normalizeRrule(Request $request): ?string
|
||||
{
|
||||
if (! $request->has('rrule')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rrule = trim((string) $request->input('rrule'));
|
||||
return $rrule === '' ? '' : $rrule;
|
||||
}
|
||||
|
||||
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
|
||||
{
|
||||
if ($rrule === null) {
|
||||
return $extra; // no change requested
|
||||
}
|
||||
|
||||
if ($rrule === '') {
|
||||
unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']);
|
||||
return $extra;
|
||||
}
|
||||
|
||||
$extra['rrule'] = $rrule;
|
||||
$extra['tzid'] = $tz;
|
||||
|
||||
$extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz);
|
||||
$extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz);
|
||||
|
||||
return $extra;
|
||||
}
|
||||
|
||||
private function normalizeDateList(mixed $value, string $tz): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = array_filter(array_map('trim', explode(',', $value)));
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(function ($item) use ($tz) {
|
||||
if (! $item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse($item, $tz)->utc()->toIso8601String();
|
||||
}, $value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve location_id from hints or geocoding
|
||||
*/
|
||||
|
||||
@ -5,8 +5,6 @@ namespace App\Http\Controllers;
|
||||
use App\Models\CalendarInstance;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Carbon\Carbon;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Reader;
|
||||
|
||||
class IcsController extends Controller
|
||||
{
|
||||
@ -27,48 +25,39 @@ class IcsController extends Controller
|
||||
|
||||
protected function generateICalendarFeed($events, string $tz): string
|
||||
{
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('VERSION', '2.0');
|
||||
$vcalendar->add('PRODID', '-//Kithkin Calendar//EN');
|
||||
$vcalendar->add('CALSCALE', 'GREGORIAN');
|
||||
$vcalendar->add('METHOD', 'PUBLISH');
|
||||
$output = [];
|
||||
$output[] = 'BEGIN:VCALENDAR';
|
||||
$output[] = 'VERSION:2.0';
|
||||
$output[] = 'PRODID:-//Kithkin Calendar//EN';
|
||||
$output[] = 'CALSCALE:GREGORIAN';
|
||||
$output[] = 'METHOD:PUBLISH';
|
||||
|
||||
foreach ($events as $event) {
|
||||
$ical = $event->calendardata ?? null;
|
||||
|
||||
if ($ical) {
|
||||
try {
|
||||
$parsed = Reader::read($ical);
|
||||
foreach ($parsed->select('VEVENT') as $vevent) {
|
||||
$vcalendar->add(clone $vevent);
|
||||
}
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
// fall through to meta-based output
|
||||
}
|
||||
}
|
||||
|
||||
$meta = $event->meta;
|
||||
|
||||
if (!$meta || !$meta->start_at || !$meta->end_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = Carbon::parse($meta->start_at)->timezone($tz);
|
||||
$end = Carbon::parse($meta->end_at)->timezone($tz);
|
||||
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis');
|
||||
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis');
|
||||
|
||||
$vevent = $vcalendar->add('VEVENT', []);
|
||||
$vevent->add('UID', $event->uid);
|
||||
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
|
||||
$vevent->add('DESCRIPTION', $meta->description ?? '');
|
||||
$vevent->add('DTSTART', $start, ['TZID' => $tz]);
|
||||
$vevent->add('DTEND', $end, ['TZID' => $tz]);
|
||||
$vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc());
|
||||
$output[] = 'BEGIN:VEVENT';
|
||||
$output[] = 'UID:' . $event->uid;
|
||||
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)');
|
||||
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? '');
|
||||
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start;
|
||||
$output[] = 'DTEND;TZID=' . $tz . ':' . $end;
|
||||
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z');
|
||||
if ($meta->location) {
|
||||
$vevent->add('LOCATION', $meta->location);
|
||||
$output[] = 'LOCATION:' . $this->escape($meta->location);
|
||||
}
|
||||
$output[] = 'END:VEVENT';
|
||||
}
|
||||
|
||||
return $vcalendar->serialize();
|
||||
$output[] = 'END:VCALENDAR';
|
||||
|
||||
return implode("\r\n", $output);
|
||||
}
|
||||
|
||||
protected function escape(?string $text): string
|
||||
|
||||
@ -53,22 +53,12 @@ class Event extends Model
|
||||
**/
|
||||
public function scopeInRange($query, $start, $end)
|
||||
{
|
||||
return $query->where(function ($q) use ($start, $end) {
|
||||
$q->whereHas('meta', function ($meta) use ($start, $end) {
|
||||
$meta->where(function ($range) use ($start, $end) {
|
||||
$range->where('start_at', '<=', $end)
|
||||
->where(function ($bounds) use ($start) {
|
||||
$bounds->where('end_at', '>=', $start)
|
||||
->orWhereNull('end_at');
|
||||
});
|
||||
})
|
||||
->orWhereNotNull('extra->rrule');
|
||||
})
|
||||
->orWhere(function ($ical) {
|
||||
$ical->where('calendardata', 'like', '%RRULE%')
|
||||
->orWhere('calendardata', 'like', '%RDATE%')
|
||||
->orWhere('calendardata', 'like', '%EXDATE%');
|
||||
});
|
||||
return $query->whereHas('meta', function ($q) use ($start, $end) {
|
||||
$q->where('start_at', '<=', $end)
|
||||
->where(function ($qq) use ($start) {
|
||||
$qq->where('end_at', '>=', $start)
|
||||
->orWhereNull('end_at');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ class User extends Authenticatable
|
||||
'firstname',
|
||||
'lastname',
|
||||
'displayname',
|
||||
'name',
|
||||
'email',
|
||||
'timezone',
|
||||
'phone',
|
||||
@ -60,62 +59,6 @@ class User extends Authenticatable
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose a Breeze-compatible "name" attribute without a physical column.
|
||||
* Preference: displayname (explicit override), then first + last, then email.
|
||||
*/
|
||||
public function getNameAttribute(): string
|
||||
{
|
||||
$displayname = is_string($this->displayname) ? trim($this->displayname) : '';
|
||||
if ($displayname !== '') {
|
||||
return $displayname;
|
||||
}
|
||||
|
||||
$first = is_string($this->firstname) ? trim($this->firstname) : '';
|
||||
$last = is_string($this->lastname) ? trim($this->lastname) : '';
|
||||
$full = trim($first . ' ' . $last);
|
||||
|
||||
if ($full !== '') {
|
||||
return $full;
|
||||
}
|
||||
|
||||
return (string) ($this->email ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map "name" writes to first/last names, keeping displayname optional.
|
||||
*/
|
||||
public function setNameAttribute(?string $value): void
|
||||
{
|
||||
$incoming = trim((string) $value);
|
||||
|
||||
$currentFirst = is_string($this->attributes['firstname'] ?? null)
|
||||
? trim((string) $this->attributes['firstname'])
|
||||
: '';
|
||||
$currentLast = is_string($this->attributes['lastname'] ?? null)
|
||||
? trim((string) $this->attributes['lastname'])
|
||||
: '';
|
||||
$currentGenerated = trim($currentFirst . ' ' . $currentLast);
|
||||
|
||||
if ($incoming === '') {
|
||||
$this->attributes['firstname'] = null;
|
||||
$this->attributes['lastname'] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+/', $incoming, 2);
|
||||
$this->attributes['firstname'] = $parts[0] ?? null;
|
||||
$this->attributes['lastname'] = $parts[1] ?? null;
|
||||
|
||||
$displayname = is_string($this->attributes['displayname'] ?? null)
|
||||
? trim((string) $this->attributes['displayname'])
|
||||
: '';
|
||||
|
||||
if ($displayname !== '' && $displayname === $currentGenerated) {
|
||||
$this->attributes['displayname'] = $incoming;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* user can own many calendars
|
||||
*/
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Calendar;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CalendarRangeResolver
|
||||
{
|
||||
public const VIEWS = ['day', 'week', 'month', 'four'];
|
||||
|
||||
/**
|
||||
* Normalize view + date inputs into a resolved view and date range.
|
||||
*
|
||||
* @return array [$view, ['start' => Carbon, 'end' => Carbon]]
|
||||
*/
|
||||
public function resolveRange(
|
||||
Request $request,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd,
|
||||
string $defaultView,
|
||||
string $defaultDate
|
||||
): array {
|
||||
$requestView = $request->query('view', $defaultView);
|
||||
$view = in_array($requestView, self::VIEWS, true)
|
||||
? $requestView
|
||||
: 'month';
|
||||
|
||||
$date = $request->query('date', $defaultDate);
|
||||
$anchor = $this->safeDate($date, $tz, $defaultDate);
|
||||
|
||||
switch ($view) {
|
||||
case 'day':
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->endOfDay();
|
||||
break;
|
||||
case 'week':
|
||||
$start = $anchor->copy()->startOfWeek($weekStart);
|
||||
$end = $anchor->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
case 'four':
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->addDays(3)->endOfDay();
|
||||
break;
|
||||
default: // month
|
||||
$start = $anchor->copy()->startOfMonth();
|
||||
$end = $anchor->copy()->endOfMonth();
|
||||
}
|
||||
|
||||
return [$view, ['start' => $start, 'end' => $end]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar grid span differs from logical range (e.g. month padding).
|
||||
*/
|
||||
public function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
|
||||
{
|
||||
switch ($view) {
|
||||
case 'day':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->endOfDay();
|
||||
break;
|
||||
case 'week':
|
||||
$start = $range['start']->copy()->startOfWeek($weekStart);
|
||||
$end = $range['start']->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
case 'four':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->addDays(3);
|
||||
break;
|
||||
default: // month
|
||||
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
|
||||
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||||
}
|
||||
|
||||
return ['start' => $start, 'end' => $end];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation dates for header controls.
|
||||
*/
|
||||
public function navDates(string $view, Carbon $start, string $tz): array
|
||||
{
|
||||
$start = $start->copy()->tz($tz);
|
||||
|
||||
return match ($view) {
|
||||
'day' => [
|
||||
'prev' => $start->copy()->subDay()->toDateString(),
|
||||
'next' => $start->copy()->addDay()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'week' => [
|
||||
'prev' => $start->copy()->subWeek()->toDateString(),
|
||||
'next' => $start->copy()->addWeek()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'four' => [
|
||||
'prev' => $start->copy()->subDays(4)->toDateString(),
|
||||
'next' => $start->copy()->addDays(4)->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
default => [
|
||||
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
|
||||
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Title text for the calendar header.
|
||||
*/
|
||||
public function headerTitle(string $view, Carbon $start, Carbon $end): array
|
||||
{
|
||||
$sameDay = $start->isSameDay($end);
|
||||
$sameMonth = $start->isSameMonth($end);
|
||||
$sameYear = $start->isSameYear($end);
|
||||
|
||||
$strong = $start->format('F');
|
||||
$span = $start->format('Y');
|
||||
|
||||
if ($view === 'day' || $sameDay) {
|
||||
return [
|
||||
'strong' => $start->format('F j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($view, ['week', 'four'], true)) {
|
||||
if ($sameMonth && $sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F') . ' to ' . $end->format('F'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
|
||||
'span' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['strong' => $strong, 'span' => $span];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe date parsing with fallback to a default date string.
|
||||
*/
|
||||
public function safeDate(string $date, string $tz, string $fallbackDate): Carbon
|
||||
{
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
|
||||
} catch (\Throwable $e) {
|
||||
return Carbon::createFromFormat('Y-m-d', $fallbackDate, $tz)->startOfDay();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Calendar;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CalendarSettingsPersister
|
||||
{
|
||||
public function defaults(User $user, string $tz): array
|
||||
{
|
||||
$defaultView = $user->getSetting('calendar.last_view', 'month');
|
||||
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
|
||||
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
|
||||
$defaultDaytimeHours = (int) $user->getSetting('calendar.daytime_hours', 0);
|
||||
|
||||
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday');
|
||||
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
|
||||
? $weekStartPref
|
||||
: 'sunday';
|
||||
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
|
||||
$weekEnd = (int) (($weekStart + 6) % 7);
|
||||
|
||||
return [
|
||||
'view' => $defaultView,
|
||||
'date' => $defaultDate,
|
||||
'density' => $defaultDensity,
|
||||
'daytime_hours' => $defaultDaytimeHours,
|
||||
'week_start_pref' => $weekStartPref,
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => $weekEnd,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveDensity(Request $request, int $defaultDensity): array
|
||||
{
|
||||
$stepMinutes = (int) $request->query('density', $defaultDensity);
|
||||
if (!in_array($stepMinutes, [15, 30, 60], true)) {
|
||||
$stepMinutes = 30;
|
||||
}
|
||||
|
||||
$labelEvery = match ($stepMinutes) {
|
||||
15 => 1,
|
||||
30 => 2,
|
||||
60 => 4,
|
||||
};
|
||||
|
||||
return [
|
||||
'step' => $stepMinutes,
|
||||
'label_every' => $labelEvery,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveDaytimeHours(Request $request, int $defaultDaytimeHours): bool
|
||||
{
|
||||
return (int) $request->query('daytime_hours', $defaultDaytimeHours) === 1;
|
||||
}
|
||||
|
||||
public function daytimeHoursRange(): array
|
||||
{
|
||||
return ['start' => 8, 'end' => 18];
|
||||
}
|
||||
|
||||
public function persist(
|
||||
User $user,
|
||||
Request $request,
|
||||
string $view,
|
||||
Carbon $rangeStart,
|
||||
int $stepMinutes,
|
||||
bool $daytimeHoursEnabled
|
||||
): void {
|
||||
if ($request->hasAny(['view', 'date', 'density'])) {
|
||||
$user->setSetting('calendar.last_view', $view);
|
||||
$user->setSetting('calendar.last_date', $rangeStart->toDateString());
|
||||
$user->setSetting('calendar.last_density', (string) $stepMinutes);
|
||||
}
|
||||
|
||||
if ($request->has('daytime_hours')) {
|
||||
$user->setSetting('calendar.daytime_hours', $daytimeHoursEnabled ? '1' : '0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,460 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Calendar;
|
||||
|
||||
use App\Services\Event\EventRecurrence;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CalendarViewBuilder
|
||||
{
|
||||
/**
|
||||
* Expand events (including recurrence) into view-ready payloads.
|
||||
*/
|
||||
public function buildEventPayloads(
|
||||
Collection $events,
|
||||
Collection $calendarMap,
|
||||
string $timeFormat,
|
||||
string $view,
|
||||
array $range,
|
||||
string $tz,
|
||||
EventRecurrence $recurrence,
|
||||
array $span,
|
||||
?array $daytimeHours = null
|
||||
): Collection {
|
||||
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
||||
$spanStartUtc = $span['start']->copy()->utc();
|
||||
$spanEndUtc = $span['end']->copy()->utc();
|
||||
$gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0;
|
||||
$gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60);
|
||||
|
||||
$payloads = $events->flatMap(function ($e) use (
|
||||
$calendarMap,
|
||||
$uiFormat,
|
||||
$view,
|
||||
$range,
|
||||
$tz,
|
||||
$recurrence,
|
||||
$spanStartUtc,
|
||||
$spanEndUtc,
|
||||
$gridStartMinutes,
|
||||
$gridEndMinutes,
|
||||
$daytimeHours
|
||||
) {
|
||||
$cal = $calendarMap[$e->calendarid];
|
||||
$timezone = $cal->timezone ?? config('app.timezone');
|
||||
|
||||
$color = $cal['meta_color']
|
||||
?? $cal['calendarcolor']
|
||||
?? default_calendar_color();
|
||||
$colorFg = $cal['meta_color_fg']
|
||||
?? contrast_text_color($color);
|
||||
|
||||
$occurrences = [];
|
||||
$isRecurring = $recurrence->isRecurring($e);
|
||||
|
||||
if ($isRecurring) {
|
||||
$occurrences = $recurrence->expand($e, $spanStartUtc, $spanEndUtc);
|
||||
}
|
||||
|
||||
if (empty($occurrences) && !$isRecurring) {
|
||||
$startUtc = $e->meta?->start_at
|
||||
? Carbon::parse($e->meta->start_at)->utc()
|
||||
: Carbon::createFromTimestamp($e->firstoccurence, 'UTC');
|
||||
$endUtc = $e->meta?->end_at
|
||||
? Carbon::parse($e->meta->end_at)->utc()
|
||||
: ($e->lastoccurence
|
||||
? Carbon::createFromTimestamp($e->lastoccurence, 'UTC')
|
||||
: $startUtc->copy());
|
||||
|
||||
$occurrences[] = [
|
||||
'start' => $startUtc,
|
||||
'end' => $endUtc,
|
||||
'recurrence_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return collect($occurrences)->map(function ($occ) use (
|
||||
$e,
|
||||
$cal,
|
||||
$uiFormat,
|
||||
$view,
|
||||
$range,
|
||||
$tz,
|
||||
$timezone,
|
||||
$color,
|
||||
$colorFg,
|
||||
$gridStartMinutes,
|
||||
$gridEndMinutes,
|
||||
$daytimeHours
|
||||
) {
|
||||
$startUtc = $occ['start'];
|
||||
$endUtc = $occ['end'];
|
||||
|
||||
$startLocal = $startUtc->copy()->timezone($timezone);
|
||||
$endLocal = $endUtc->copy()->timezone($timezone);
|
||||
|
||||
$startForGrid = $startUtc->copy()->tz($tz);
|
||||
$endForGrid = $endUtc->copy()->tz($tz);
|
||||
|
||||
if ($daytimeHours) {
|
||||
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
|
||||
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
|
||||
|
||||
if ($endMinutes <= $gridStartMinutes || $startMinutes >= $gridEndMinutes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayStartMinutes = max($startMinutes, $gridStartMinutes);
|
||||
$displayEndMinutes = min($endMinutes, $gridEndMinutes);
|
||||
|
||||
$startForGrid = $startForGrid->copy()->startOfDay()->addMinutes($displayStartMinutes);
|
||||
$endForGrid = $endForGrid->copy()->startOfDay()->addMinutes($displayEndMinutes);
|
||||
}
|
||||
|
||||
$placement = $this->slotPlacement(
|
||||
$startForGrid,
|
||||
$endForGrid,
|
||||
$range['start']->copy()->tz($tz),
|
||||
$view,
|
||||
15,
|
||||
$gridStartMinutes,
|
||||
$gridEndMinutes
|
||||
);
|
||||
|
||||
$occurrenceId = $occ['recurrence_id']
|
||||
? ($e->id . ':' . $occ['recurrence_id'])
|
||||
: (string) $e->id;
|
||||
|
||||
return [
|
||||
'id' => $e->id,
|
||||
'occurrence_id' => $occurrenceId,
|
||||
'occurrence' => $occ['recurrence_id']
|
||||
? $startUtc->toIso8601String()
|
||||
: null,
|
||||
'calendar_id' => $e->calendarid,
|
||||
'calendar_slug' => $cal->slug,
|
||||
'title' => $e->meta->title ?? 'No title',
|
||||
'description' => $e->meta->description ?? 'No description.',
|
||||
'start' => $startUtc->toIso8601String(),
|
||||
'end' => $endUtc->toIso8601String(),
|
||||
'start_ui' => $startLocal->format($uiFormat),
|
||||
'end_ui' => $endLocal->format($uiFormat),
|
||||
'timezone' => $timezone,
|
||||
'visible' => $cal->visible,
|
||||
'color' => $color,
|
||||
'color_fg' => $colorFg,
|
||||
'start_row' => $placement['start_row'],
|
||||
'end_row' => $placement['end_row'],
|
||||
'row_span' => $placement['row_span'],
|
||||
'start_col' => $placement['start_col'],
|
||||
'duration' => $placement['duration'],
|
||||
];
|
||||
})->filter()->values();
|
||||
})->filter();
|
||||
|
||||
// ensure chronological ordering across calendars for all views
|
||||
return $payloads
|
||||
->sortBy('start')
|
||||
->keyBy('occurrence_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble an array of day-objects for the requested view.
|
||||
*/
|
||||
public function buildCalendarGrid(
|
||||
string $view,
|
||||
array $range,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
array $span
|
||||
): array {
|
||||
['start' => $grid_start, 'end' => $grid_end] = $span;
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
$events_by_day = [];
|
||||
foreach ($events as $ev) {
|
||||
$evTz = $ev['timezone'] ?? $tz;
|
||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
||||
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
|
||||
|
||||
for ($d = $start->copy()->startOfDay();
|
||||
$d->lte($end->copy()->endOfDay());
|
||||
$d->addDay()) {
|
||||
$key = $d->toDateString();
|
||||
$events_by_day[$key][] = $ev['occurrence_id'] ?? $ev['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$days = [];
|
||||
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
|
||||
$iso = $day->toDateString();
|
||||
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'label' => $day->format('j'),
|
||||
'in_month' => $day->month === $range['start']->month,
|
||||
'is_today' => $day->isSameDay($today),
|
||||
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
|
||||
];
|
||||
}
|
||||
|
||||
return $view === 'month'
|
||||
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
|
||||
: ['days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mini-month grid for day buttons.
|
||||
*/
|
||||
public function buildMiniGrid(
|
||||
Carbon $monthStart,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd
|
||||
): array {
|
||||
$monthStart = $monthStart->copy()->tz($tz);
|
||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||
|
||||
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
|
||||
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
|
||||
|
||||
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
|
||||
$gridEnd->addWeek();
|
||||
}
|
||||
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
$byDay = [];
|
||||
foreach ($events as $ev) {
|
||||
$s = Carbon::parse($ev['start'])->tz($tz);
|
||||
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
|
||||
|
||||
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
|
||||
$byDay[$d->toDateString()][] = $ev['occurrence_id'] ?? $ev['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$days = [];
|
||||
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
|
||||
$iso = $d->toDateString();
|
||||
$days[] = [
|
||||
'date' => $iso,
|
||||
'label' => $d->format('j'),
|
||||
'in_month' => $d->between($monthStart, $monthEnd),
|
||||
'is_today' => $iso === $today,
|
||||
'events' => $byDay[$iso] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
return ['days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the time gutter for time-based views.
|
||||
*/
|
||||
public function timeSlots(
|
||||
Carbon $dayStart,
|
||||
string $tz,
|
||||
string $timeFormat,
|
||||
?array $daytimeHours = null
|
||||
): array {
|
||||
$minutesPerSlot = 15;
|
||||
$startMinutes = 0;
|
||||
$endMinutes = 24 * 60;
|
||||
|
||||
if (is_array($daytimeHours)) {
|
||||
$startMinutes = (int) $daytimeHours['start'] * 60;
|
||||
$endMinutes = (int) $daytimeHours['end'] * 60;
|
||||
}
|
||||
|
||||
$slotsPerDay = intdiv(max(0, $endMinutes - $startMinutes), $minutesPerSlot);
|
||||
|
||||
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
|
||||
|
||||
$slots = [];
|
||||
$t = $dayStart->copy()->tz($tz)->startOfDay()->addMinutes($startMinutes);
|
||||
|
||||
for ($i = 0; $i < $slotsPerDay; $i++) {
|
||||
$slots[] = [
|
||||
'iso' => $t->toIso8601String(),
|
||||
'label' => $t->format($format),
|
||||
'key' => $t->format('H:i'),
|
||||
'index' => $i,
|
||||
'minutes' => $startMinutes + ($i * $minutesPerSlot),
|
||||
'duration' => $minutesPerSlot,
|
||||
];
|
||||
|
||||
$t->addMinutes($minutesPerSlot);
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the specific view's date headers.
|
||||
*/
|
||||
public function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
|
||||
{
|
||||
$start = $range['start']->copy()->tz($tz);
|
||||
$end = $range['end']->copy()->tz($tz);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
if ($view === 'month') {
|
||||
return collect($this->weekdayHeaders($tz, $weekStart))
|
||||
->map(fn ($h) => $h + ['is_today' => false])
|
||||
->all();
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
|
||||
$date = $d->toDateString();
|
||||
$headers[] = [
|
||||
'date' => $d->toDateString(),
|
||||
'day' => $d->format('j'),
|
||||
'dow' => $d->translatedFormat('l'),
|
||||
'dow_short' => $d->translatedFormat('D'),
|
||||
'month' => $d->translatedFormat('M'),
|
||||
'is_today' => $date === $today,
|
||||
];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific headers for month views (full and mini).
|
||||
*/
|
||||
public function weekdayHeaders(string $tz, int $weekStart): array
|
||||
{
|
||||
$headers = [];
|
||||
$d = Carbon::now($tz)->startOfWeek($weekStart);
|
||||
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$headers[] = [
|
||||
'key' => $i,
|
||||
'label' => $d->translatedFormat('D'),
|
||||
];
|
||||
$d->addDay();
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the "now" indicator on the grid for time-based views.
|
||||
*/
|
||||
public function nowIndicator(
|
||||
string $view,
|
||||
array $range,
|
||||
string $tz,
|
||||
int $minutesPerSlot = 15,
|
||||
int $gutterCols = 1,
|
||||
?array $daytimeHours = null
|
||||
): array {
|
||||
if (!in_array($view, ['day', 'week', 'four'], true)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
$now = Carbon::now($tz);
|
||||
|
||||
$start = $range['start']->copy()->tz($tz)->startOfDay();
|
||||
$end = $range['end']->copy()->tz($tz)->endOfDay();
|
||||
|
||||
if (!$now->betweenIncluded($start, $end)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
$minutes = ($now->hour * 60) + $now->minute;
|
||||
$gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0;
|
||||
$gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60);
|
||||
|
||||
if ($daytimeHours && ($minutes < $gridStartMinutes || $minutes >= $gridEndMinutes)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
$relativeMinutes = $minutes - $gridStartMinutes;
|
||||
$relativeMinutes = max(0, min($relativeMinutes, $gridEndMinutes - $gridStartMinutes));
|
||||
|
||||
$row = intdiv($relativeMinutes, $minutesPerSlot) + 1;
|
||||
$offset = ($relativeMinutes % $minutesPerSlot) / $minutesPerSlot;
|
||||
|
||||
if ($view === 'day') {
|
||||
$dayCol = 1;
|
||||
} else {
|
||||
$todayStart = $now->copy()->startOfDay();
|
||||
$dayCol = $start->diffInDays($todayStart) + 1;
|
||||
}
|
||||
|
||||
$dayCol = (int) $dayCol;
|
||||
|
||||
return [
|
||||
'show' => true,
|
||||
'row' => (int) $row,
|
||||
'offset' => round($offset, 4),
|
||||
'day_col' => $dayCol,
|
||||
'col_start' => $dayCol,
|
||||
'col_end' => $dayCol + 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-based layout slot placement.
|
||||
*/
|
||||
private function slotPlacement(
|
||||
Carbon $startLocal,
|
||||
?Carbon $endLocal,
|
||||
Carbon $rangeStart,
|
||||
string $view,
|
||||
int $minutesPerSlot = 15,
|
||||
int $gridStartMinutes = 0,
|
||||
int $gridEndMinutes = 1440
|
||||
): array {
|
||||
$start = $startLocal->copy();
|
||||
$end = ($endLocal ?? $startLocal)->copy();
|
||||
|
||||
$startMinutes = (($start->hour * 60) + $start->minute) - $gridStartMinutes;
|
||||
$endMinutes = (($end->hour * 60) + $end->minute) - $gridStartMinutes;
|
||||
|
||||
$maxStart = max(0, ($gridEndMinutes - $gridStartMinutes) - $minutesPerSlot);
|
||||
$startMinutes = $this->snapToSlot($startMinutes, $minutesPerSlot, 0, $maxStart);
|
||||
$endMinutes = $this->snapToSlot(
|
||||
$endMinutes,
|
||||
$minutesPerSlot,
|
||||
$minutesPerSlot,
|
||||
max($minutesPerSlot, $gridEndMinutes - $gridStartMinutes)
|
||||
);
|
||||
|
||||
if ($endMinutes <= $startMinutes) {
|
||||
$endMinutes = min($startMinutes + $minutesPerSlot, $gridEndMinutes - $gridStartMinutes);
|
||||
}
|
||||
|
||||
$startRow = intdiv($startMinutes, $minutesPerSlot) + 1;
|
||||
$rowSpan = max(1, intdiv($endMinutes - $startMinutes, $minutesPerSlot));
|
||||
$endRow = $startRow + $rowSpan;
|
||||
|
||||
$maxCols = match ($view) {
|
||||
'day' => 1,
|
||||
'four' => 4,
|
||||
'week' => 7,
|
||||
default => 1,
|
||||
};
|
||||
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
|
||||
$startCol = max(1, min($maxCols, $startCol));
|
||||
|
||||
return [
|
||||
'start_row' => $startRow,
|
||||
'end_row' => $endRow,
|
||||
'row_span' => $rowSpan,
|
||||
'duration' => $endMinutes - $startMinutes,
|
||||
'start_col' => $startCol,
|
||||
];
|
||||
}
|
||||
|
||||
private function snapToSlot(int $minutes, int $slot, int $min, int $max): int
|
||||
{
|
||||
$rounded = (int) round($minutes / $slot) * $slot;
|
||||
return max($min, min($rounded, $max));
|
||||
}
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Event;
|
||||
|
||||
use App\Models\Event;
|
||||
use Carbon\Carbon;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Support\Str;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Reader;
|
||||
use Sabre\VObject\Recur\EventIterator;
|
||||
|
||||
class EventRecurrence
|
||||
{
|
||||
/**
|
||||
* Build a VCALENDAR string from core fields and optional recurrence.
|
||||
*/
|
||||
public function buildCalendar(array $data): string
|
||||
{
|
||||
$vcalendar = new VCalendar();
|
||||
$vcalendar->add('PRODID', '-//Kithkin//Laravel CalDAV//EN');
|
||||
$vcalendar->add('VERSION', '2.0');
|
||||
$vcalendar->add('CALSCALE', 'GREGORIAN');
|
||||
$vevent = $vcalendar->add('VEVENT', []);
|
||||
|
||||
$uid = $data['uid'];
|
||||
$startUtc = $data['start_utc'];
|
||||
$endUtc = $data['end_utc'];
|
||||
$tzid = $data['tzid'] ?? null;
|
||||
|
||||
$vevent->add('UID', $uid);
|
||||
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
|
||||
|
||||
if ($tzid) {
|
||||
$startLocal = $startUtc->copy()->tz($tzid);
|
||||
$endLocal = $endUtc->copy()->tz($tzid);
|
||||
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
|
||||
$vevent->add('DTEND', $endLocal, ['TZID' => $tzid]);
|
||||
} else {
|
||||
$vevent->add('DTSTART', $startUtc->copy()->utc());
|
||||
$vevent->add('DTEND', $endUtc->copy()->utc());
|
||||
}
|
||||
|
||||
if (!empty($data['summary'])) {
|
||||
$vevent->add('SUMMARY', $data['summary']);
|
||||
}
|
||||
|
||||
if (!empty($data['description'])) {
|
||||
$vevent->add('DESCRIPTION', $data['description']);
|
||||
}
|
||||
|
||||
if (!empty($data['location'])) {
|
||||
$vevent->add('LOCATION', $data['location']);
|
||||
}
|
||||
|
||||
$rrule = $data['rrule'] ?? null;
|
||||
if ($rrule) {
|
||||
$vevent->add('RRULE', $rrule);
|
||||
}
|
||||
|
||||
$exdates = $data['exdate'] ?? [];
|
||||
if (!empty($exdates)) {
|
||||
foreach ($exdates as $ex) {
|
||||
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
|
||||
if ($tzid) {
|
||||
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
|
||||
} else {
|
||||
$vevent->add('EXDATE', $dt->utc());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rdates = $data['rdate'] ?? [];
|
||||
if (!empty($rdates)) {
|
||||
foreach ($rdates as $r) {
|
||||
$dt = Carbon::parse($r, $tzid ?: 'UTC');
|
||||
if ($tzid) {
|
||||
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
|
||||
} else {
|
||||
$vevent->add('RDATE', $dt->utc());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $vcalendar->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stored event contains recurrence data.
|
||||
*/
|
||||
public function isRecurring(Event $event): bool
|
||||
{
|
||||
$extra = $event->meta?->extra ?? [];
|
||||
if (!empty($extra['rrule'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand recurring instances within the requested range.
|
||||
*
|
||||
* Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null]
|
||||
*/
|
||||
public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array
|
||||
{
|
||||
$vcalendar = $this->readCalendar($event->calendardata);
|
||||
if (!$vcalendar || empty($vcalendar->VEVENT)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vevent = $vcalendar->VEVENT;
|
||||
$uid = (string) $vevent->UID;
|
||||
|
||||
$startTz = $vevent->DTSTART?->getDateTime()?->getTimezone()
|
||||
?? new DateTimeZone('UTC');
|
||||
|
||||
$iter = new EventIterator($vcalendar, $uid);
|
||||
$iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime());
|
||||
|
||||
$items = [];
|
||||
while ($iter->valid()) {
|
||||
$start = Carbon::instance($iter->getDTStart());
|
||||
$end = Carbon::instance($iter->getDTEnd());
|
||||
|
||||
if ($start->gt($rangeEnd)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$startUtc = $start->copy()->utc();
|
||||
$endUtc = $end->copy()->utc();
|
||||
$items[] = [
|
||||
'start' => $startUtc,
|
||||
'end' => $endUtc,
|
||||
'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'),
|
||||
];
|
||||
|
||||
$iter->next();
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single occurrence by its DTSTART.
|
||||
*/
|
||||
public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array
|
||||
{
|
||||
$rangeStart = $occurrenceStart->copy()->subDay();
|
||||
$rangeEnd = $occurrenceStart->copy()->addDay();
|
||||
|
||||
foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) {
|
||||
if ($occ['start']->equalTo($occurrenceStart)) {
|
||||
return $occ;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function extractRrule(Event $event): ?string
|
||||
{
|
||||
$vcalendar = $this->readCalendar($event->calendardata);
|
||||
if (!$vcalendar || empty($vcalendar->VEVENT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vevent = $vcalendar->VEVENT;
|
||||
return isset($vevent->RRULE) ? (string) $vevent->RRULE : null;
|
||||
}
|
||||
|
||||
private function readCalendar(?string $ical): ?VCalendar
|
||||
{
|
||||
if (!$ical) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Reader::read($ical);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
@ -13,38 +12,26 @@ return new class extends Migration
|
||||
return base_path("vendor/sabre/dav/examples/sql/{$file}");
|
||||
}
|
||||
|
||||
private function prefix(): string
|
||||
{
|
||||
$driver = DB::connection()->getDriverName();
|
||||
|
||||
return match ($driver) {
|
||||
'sqlite' => 'sqlite',
|
||||
'pgsql' => 'pgsql',
|
||||
default => 'mysql',
|
||||
};
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$prefix = $this->prefix();
|
||||
Schema::disableForeignKeyConstraints();
|
||||
// Disable FK checks for smooth batch execution
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
// Principals (users & groups)
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.principals.sql")));
|
||||
DB::unprepared(File::get($this->sql('mysql.principals.sql')));
|
||||
|
||||
// CalDAV calendars + objects
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql")));
|
||||
DB::unprepared(File::get($this->sql('mysql.calendars.sql')));
|
||||
|
||||
// CardDAV address books + cards
|
||||
DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql")));
|
||||
DB::unprepared(File::get($this->sql('mysql.addressbooks.sql')));
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->prefix();
|
||||
Schema::disableForeignKeyConstraints();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
// Drop in reverse dependency order
|
||||
DB::statement('DROP TABLE IF EXISTS
|
||||
@ -60,6 +47,6 @@ return new class extends Migration
|
||||
groupmembers
|
||||
');
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
};
|
||||
|
||||
@ -9,9 +9,7 @@ return new class extends Migration
|
||||
// add composite + geo + optional fulltext indexes to locations
|
||||
public function up(): void
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
Schema::table('locations', function (Blueprint $table) use ($driver) {
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
// composite btree index for common lookups
|
||||
$table->index(
|
||||
['display_name', 'city', 'state', 'postal', 'country'],
|
||||
@ -23,23 +21,17 @@ return new class extends Migration
|
||||
|
||||
// optional: fulltext index for free-form text searching
|
||||
// note: requires mysql/mariadb version with innodb fulltext support
|
||||
if (in_array($driver, ['mysql', 'pgsql'], true)) {
|
||||
$table->fullText('raw_address', 'locations_raw_address_fulltext');
|
||||
}
|
||||
$table->fullText('raw_address', 'locations_raw_address_fulltext');
|
||||
});
|
||||
}
|
||||
|
||||
// drop the indexes added in up()
|
||||
public function down(): void
|
||||
{
|
||||
$driver = Schema::getConnection()->getDriverName();
|
||||
|
||||
Schema::table('locations', function (Blueprint $table) use ($driver) {
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
$table->dropIndex('locations_name_city_idx');
|
||||
$table->dropIndex('locations_lat_lon_idx');
|
||||
if (in_array($driver, ['mysql', 'pgsql'], true)) {
|
||||
$table->dropFullText('locations_raw_address_fulltext');
|
||||
}
|
||||
$table->dropFullText('locations_raw_address_fulltext');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -8,7 +8,6 @@ use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\User;
|
||||
use App\Services\Event\EventRecurrence;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
@ -218,68 +217,6 @@ ICS;
|
||||
);
|
||||
};
|
||||
|
||||
$recurrence = new EventRecurrence();
|
||||
|
||||
$insertRecurringEvent = function (
|
||||
Carbon $start,
|
||||
string $summary,
|
||||
string $locationKey,
|
||||
string $rrule,
|
||||
string $tz
|
||||
) use ($calId, $locationIdMap, $locationSeeds, $recurrence) {
|
||||
$uid = Str::uuid().'@'.parse_url(config('app.url'), PHP_URL_HOST);
|
||||
$end = $start->copy()->addHour();
|
||||
|
||||
$startUtc = $start->copy()->utc();
|
||||
$endUtc = $end->copy()->utc();
|
||||
|
||||
$locationDisplay = $locationKey;
|
||||
$locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
|
||||
$icalLocation = $locationRaw ?? $locationDisplay;
|
||||
|
||||
$ical = $recurrence->buildCalendar([
|
||||
'uid' => $uid,
|
||||
'start_utc' => $startUtc,
|
||||
'end_utc' => $endUtc,
|
||||
'summary' => $summary,
|
||||
'description' => 'Automatically seeded recurring event',
|
||||
'location' => $icalLocation,
|
||||
'tzid' => $tz,
|
||||
'rrule' => $rrule,
|
||||
]);
|
||||
|
||||
$eventId = DB::table('calendarobjects')->insertGetId([
|
||||
'calendarid' => $calId,
|
||||
'uri' => Str::uuid().'.ics',
|
||||
'lastmodified' => time(),
|
||||
'etag' => md5($ical),
|
||||
'size' => strlen($ical),
|
||||
'componenttype' => 'VEVENT',
|
||||
'uid' => $uid,
|
||||
'calendardata' => $ical,
|
||||
]);
|
||||
|
||||
DB::table('event_meta')->updateOrInsert(
|
||||
['event_id' => $eventId],
|
||||
[
|
||||
'title' => $summary,
|
||||
'description' => 'Automatically seeded recurring event',
|
||||
'location' => $locationRaw ? null : $locationDisplay,
|
||||
'location_id' => $locationIdMap[$locationKey] ?? null,
|
||||
'all_day' => false,
|
||||
'category' => 'Demo',
|
||||
'start_at' => $startUtc,
|
||||
'end_at' => $endUtc,
|
||||
'extra' => json_encode([
|
||||
'rrule' => $rrule,
|
||||
'tzid' => $tz,
|
||||
]),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* create events
|
||||
@ -289,7 +226,7 @@ ICS;
|
||||
$now = Carbon::now($tz)->setSeconds(0);
|
||||
|
||||
// 3 events today
|
||||
$insertEvent($now->copy(), 'Playground with James', 'McCahill Park');
|
||||
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
|
||||
$insertEvent($now->copy()->addHours(2), 'Lunch with Daniel', 'Home');
|
||||
$insertEvent($now->copy()->addHours(4), 'Baseball practice', 'Meadow Park');
|
||||
|
||||
@ -307,16 +244,6 @@ ICS;
|
||||
$insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary');
|
||||
$insertEvent($future5b, 'Family game night', 'Living Room');
|
||||
|
||||
// recurring: weekly on Mon/Wed for 8 weeks at 6:30pm
|
||||
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
|
||||
$insertRecurringEvent(
|
||||
$recurringStart,
|
||||
'Evening run',
|
||||
'McCahill Park',
|
||||
'FREQ=WEEKLY;BYDAY=MO,WE;COUNT=16',
|
||||
$tz
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* address books
|
||||
|
||||
@ -46,35 +46,6 @@ return [
|
||||
'saved' => 'Your calendar settings have been saved!',
|
||||
'title' => 'Calendar settings',
|
||||
],
|
||||
'timezone_help' => 'You can override your default time zone here.',
|
||||
'toggle_sidebar' => 'Toggle calendar sidebar',
|
||||
'event' => [
|
||||
'when' => 'When',
|
||||
'all_day' => 'All day',
|
||||
'location' => 'Location',
|
||||
'map_coming' => 'Map preview coming soon.',
|
||||
'no_location' => 'No location set.',
|
||||
'details' => 'Details',
|
||||
'repeats' => 'Repeats',
|
||||
'does_not_repeat' => 'Does not repeat',
|
||||
'category' => 'Category',
|
||||
'none' => 'None',
|
||||
'visibility' => 'Visibility',
|
||||
'private' => 'Private',
|
||||
'default' => 'Default',
|
||||
'all_day_handling' => 'All-day handling',
|
||||
'timed' => 'Timed',
|
||||
'all_day_coming' => 'Multi-day all-day UI coming soon',
|
||||
'alerts' => 'Alerts',
|
||||
'reminder' => 'Reminder',
|
||||
'minutes_before' => 'minutes before',
|
||||
'alerts_coming' => 'No alerts set. (Coming soon)',
|
||||
'invitees' => 'Invitees',
|
||||
'invitees_coming' => 'Invitees and RSVP tracking coming soon.',
|
||||
'attachments' => 'Attachments',
|
||||
'attachments_coming' => 'Attachment support coming soon.',
|
||||
'notes' => 'Notes',
|
||||
'no_description' => 'No description yet.',
|
||||
],
|
||||
'timezone_help' => 'You can override your default time zone here.'
|
||||
|
||||
];
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Account Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Account, profile, and user settings language lines.
|
||||
|
|
||||
*/
|
||||
|
||||
// addresses
|
||||
'address' => [
|
||||
'city' => 'Citta',
|
||||
'country' => 'Paese',
|
||||
'home' => 'Indirizzo di casa',
|
||||
'label' => 'Etichetta indirizzo',
|
||||
'line1' => 'Indirizzo riga 1',
|
||||
'line2' => 'Indirizzo riga 2',
|
||||
'state' => 'Provincia',
|
||||
'work' => 'Indirizzo di lavoro',
|
||||
'zip' => 'CAP',
|
||||
],
|
||||
'billing' => [
|
||||
'home' => 'Usa il tuo indirizzo di casa per la fatturazione',
|
||||
'work' => 'Usa il tuo indirizzo di lavoro per la fatturazione',
|
||||
],
|
||||
'delete' => 'Elimina account',
|
||||
'delete-your' => 'Elimina il tuo account',
|
||||
'delete-confirm' => 'Elimina davvero il mio account!',
|
||||
'email' => 'Email',
|
||||
'email_address' => 'Indirizzo email',
|
||||
'first_name' => 'Nome',
|
||||
'last_name' => 'Cognome',
|
||||
'phone' => 'Numero di telefono',
|
||||
'settings' => [
|
||||
'addresses' => [
|
||||
'title' => 'Indirizzi',
|
||||
'subtitle' => 'Gestisci i tuoi indirizzi di casa e lavoro e scegli quale usare per la fatturazione.',
|
||||
],
|
||||
'delete' => [
|
||||
'title' => 'Qui ci sono draghi',
|
||||
'subtitle' => 'Elimina il tuo account e rimuovi tutte le informazioni dal nostro database. Non puo essere annullato, quindi consigliamo di esportare i tuoi dati prima e migrare a un nuovo provider.',
|
||||
'explanation' => 'Nota: non e come altre app che "eliminano" i dati—non stiamo impostando <code>is_deleted = 1</code>, li stiamo rimuovendo dal nostro database.',
|
||||
],
|
||||
'delete-confirm' => [
|
||||
'title' => 'Conferma eliminazione account',
|
||||
'subtitle' => 'Inserisci la tua password e conferma che vuoi eliminare definitivamente il tuo account.',
|
||||
],
|
||||
'information' => [
|
||||
'title' => 'Informazioni personali',
|
||||
'subtitle' => 'Il tuo nome, email e altri dettagli principali del account.',
|
||||
],
|
||||
'locale' => [
|
||||
'title' => 'Preferenze locali',
|
||||
'subtitle' => 'Posizione, fuso orario e altre preferenze regionali per calendari ed eventi.'
|
||||
],
|
||||
'password' => [
|
||||
'title' => 'Password',
|
||||
'subtitle' => 'Assicurati che il tuo account usi una password lunga e casuale per restare sicuro. Consigliamo anche un password manager!',
|
||||
],
|
||||
'title' => 'Impostazioni account',
|
||||
],
|
||||
'title' => 'Account',
|
||||
|
||||
];
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used during authentication for various
|
||||
| messages that we need to display to the user. You are free to modify
|
||||
| these language lines according to your application's requirements.
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'Queste credenziali non corrispondono ai nostri record.',
|
||||
'password' => 'La password fornita non e corretta.',
|
||||
'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.',
|
||||
|
||||
];
|
||||
@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Calendar Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used throughout the calendar app,
|
||||
| including calendar settings and events.
|
||||
|
|
||||
*/
|
||||
|
||||
'color' => 'Colore',
|
||||
'create' => 'Crea calendario',
|
||||
'description' => 'Descrizione',
|
||||
'ics' => [
|
||||
'url' => 'URL ICS',
|
||||
'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.',
|
||||
],
|
||||
'mine' => 'I miei calendari',
|
||||
'name' => 'Nome calendario',
|
||||
'settings' => [
|
||||
'calendar' => [
|
||||
'title' => 'Impostazioni calendario',
|
||||
'subtitle' => 'Dettagli e impostazioni per <strong>:calendar</strong>.'
|
||||
],
|
||||
'create' => [
|
||||
'title' => 'Crea un calendario',
|
||||
'subtitle' => 'Crea un nuovo calendario locale.',
|
||||
],
|
||||
'display' => [
|
||||
'title' => 'Preferenze di visualizzazione',
|
||||
'subtitle' => 'Regola aspetto e comportamento dei tuoi calendari.'
|
||||
],
|
||||
'language_region' => [
|
||||
'title' => 'Lingua e regione',
|
||||
'subtitle' => 'Scegli la lingua predefinita, la regione e le preferenze di formattazione. Queste influenzano come date e orari sono mostrati nei calendari e negli eventi.',
|
||||
],
|
||||
'my_calendars' => 'Impostazioni per i miei calendari',
|
||||
'subscribe' => [
|
||||
'title' => 'Iscriviti a un calendario',
|
||||
'subtitle' => 'Aggiungi un calendario `.ics` da un altro servizio',
|
||||
],
|
||||
'saved' => 'Le impostazioni del calendario sono state salvate!',
|
||||
'title' => 'Impostazioni calendario',
|
||||
],
|
||||
'timezone_help' => 'Puoi sovrascrivere il tuo fuso orario predefinito qui.',
|
||||
'toggle_sidebar' => 'Mostra o nascondi la barra laterale del calendario',
|
||||
'event' => [
|
||||
'when' => 'Quando',
|
||||
'all_day' => 'Tutto il giorno',
|
||||
'location' => 'Luogo',
|
||||
'map_coming' => 'Anteprima mappa in arrivo.',
|
||||
'no_location' => 'Nessun luogo impostato.',
|
||||
'details' => 'Dettagli',
|
||||
'repeats' => 'Ripete',
|
||||
'does_not_repeat' => 'Non si ripete',
|
||||
'category' => 'Categoria',
|
||||
'none' => 'Nessuno',
|
||||
'visibility' => 'Visibilita',
|
||||
'private' => 'Privato',
|
||||
'default' => 'Predefinito',
|
||||
'all_day_handling' => 'Gestione giornata intera',
|
||||
'timed' => 'Con orario',
|
||||
'all_day_coming' => 'UI giornate intere multi-giorno in arrivo',
|
||||
'alerts' => 'Avvisi',
|
||||
'reminder' => 'Promemoria',
|
||||
'minutes_before' => 'minuti prima',
|
||||
'alerts_coming' => 'Nessun avviso impostato. (In arrivo)',
|
||||
'invitees' => 'Invitati',
|
||||
'invitees_coming' => 'Invitati e RSVP in arrivo.',
|
||||
'attachments' => 'Allegati',
|
||||
'attachments_coming' => 'Supporto allegati in arrivo.',
|
||||
'notes' => 'Note',
|
||||
'no_description' => 'Nessuna descrizione.',
|
||||
],
|
||||
|
||||
];
|
||||
@ -2,41 +2,10 @@
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Common words and phrases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Generic words used throughout the app in more than one location.
|
||||
|
|
||||
*/
|
||||
|
||||
'address' => 'Indirizzo',
|
||||
'addresses' => 'Indirizzi',
|
||||
'calendar' => 'Calendario',
|
||||
'calendars' => 'Calendari',
|
||||
'cancel' => 'Annulla',
|
||||
'cancel_back' => 'Annulla e torna indietro',
|
||||
'cancel_funny' => 'Portami via',
|
||||
'date' => 'Data',
|
||||
'date_select' => 'Seleziona una data',
|
||||
'date_format' => 'Formato data',
|
||||
'date_format_select' => 'Seleziona un formato data',
|
||||
'event' => 'Evento',
|
||||
'events' => 'Eventi',
|
||||
'language' => 'Lingua',
|
||||
'language_select' => 'Seleziona una lingua',
|
||||
'password' => 'Password',
|
||||
'region' => 'Regione',
|
||||
'region_select' => 'Seleziona una regione',
|
||||
'save_changes' => 'Salva modifiche',
|
||||
'settings' => 'Impostazioni',
|
||||
'time' => 'Ora',
|
||||
'time_select' => 'Seleziona un orario',
|
||||
'time_format' => 'Formato ora',
|
||||
'time_format_select' => 'Seleziona un formato ora',
|
||||
'timezone' => 'Fuso orario',
|
||||
'timezone_default' => 'Fuso orario predefinito',
|
||||
'timezone_select' => 'Seleziona un fuso orario',
|
||||
|
||||
];
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pagination Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used by the paginator library to build
|
||||
| the simple pagination links. You are free to change them to anything
|
||||
| you want to customize your views to better match your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'previous' => '« Precedente',
|
||||
'next' => 'Successivo »',
|
||||
|
||||
];
|
||||
@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are the default lines which match reasons
|
||||
| that are given by the password broker for a password update attempt
|
||||
| outcome such as failure due to an invalid password / reset token.
|
||||
|
|
||||
*/
|
||||
|
||||
'reset' => 'La tua password e stata reimpostata.',
|
||||
'sent' => 'Ti abbiamo inviato via email il link per reimpostare la password.',
|
||||
'throttled' => 'Attendi prima di riprovare.',
|
||||
'token' => 'Questo token di reimpostazione password non e valido.',
|
||||
'user' => 'Non troviamo un utente con questo indirizzo email.',
|
||||
|
||||
];
|
||||
@ -1,198 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines contain the default error messages used by
|
||||
| the validator class. Some of these rules have multiple versions such
|
||||
| as the size rules. Feel free to tweak each of these messages here.
|
||||
|
|
||||
*/
|
||||
|
||||
'accepted' => 'Il campo :attribute deve essere accettato.',
|
||||
'accepted_if' => 'Il campo :attribute deve essere accettato quando :other e :value.',
|
||||
'active_url' => 'Il campo :attribute deve essere un URL valido.',
|
||||
'after' => 'Il campo :attribute deve essere una data successiva a :date.',
|
||||
'after_or_equal' => 'Il campo :attribute deve essere una data successiva o uguale a :date.',
|
||||
'alpha' => 'Il campo :attribute deve contenere solo lettere.',
|
||||
'alpha_dash' => 'Il campo :attribute deve contenere solo lettere, numeri, trattini e underscore.',
|
||||
'alpha_num' => 'Il campo :attribute deve contenere solo lettere e numeri.',
|
||||
'any_of' => 'Il campo :attribute non e valido.',
|
||||
'array' => 'Il campo :attribute deve essere un array.',
|
||||
'ascii' => 'Il campo :attribute deve contenere solo caratteri alfanumerici a singolo byte e simboli.',
|
||||
'before' => 'Il campo :attribute deve essere una data precedente a :date.',
|
||||
'before_or_equal' => 'Il campo :attribute deve essere una data precedente o uguale a :date.',
|
||||
'between' => [
|
||||
'array' => 'Il campo :attribute deve avere tra :min e :max elementi.',
|
||||
'file' => 'Il campo :attribute deve essere tra :min e :max kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere tra :min e :max.',
|
||||
'string' => 'Il campo :attribute deve essere tra :min e :max caratteri.',
|
||||
],
|
||||
'boolean' => 'Il campo :attribute deve essere vero o falso.',
|
||||
'can' => 'Il campo :attribute contiene un valore non autorizzato.',
|
||||
'confirmed' => 'La conferma del campo :attribute non corrisponde.',
|
||||
'contains' => 'Il campo :attribute non contiene un valore richiesto.',
|
||||
'current_password' => 'La password inserita non e corretta.',
|
||||
'date' => 'Il campo :attribute deve essere una data valida.',
|
||||
'date_equals' => 'Il campo :attribute deve essere una data uguale a :date.',
|
||||
'date_format' => 'Il campo :attribute deve corrispondere al formato :format.',
|
||||
'decimal' => 'Il campo :attribute deve avere :decimal decimali.',
|
||||
'declined' => 'Il campo :attribute deve essere rifiutato.',
|
||||
'declined_if' => 'Il campo :attribute deve essere rifiutato quando :other e :value.',
|
||||
'different' => 'Il campo :attribute e :other devono essere diversi.',
|
||||
'digits' => 'Il campo :attribute deve essere di :digits cifre.',
|
||||
'digits_between' => 'Il campo :attribute deve essere tra :min e :max cifre.',
|
||||
'dimensions' => 'Il campo :attribute ha dimensioni immagine non valide.',
|
||||
'distinct' => 'Il campo :attribute ha un valore duplicato.',
|
||||
'doesnt_end_with' => 'Il campo :attribute non deve terminare con uno dei seguenti: :values.',
|
||||
'doesnt_start_with' => 'Il campo :attribute non deve iniziare con uno dei seguenti: :values.',
|
||||
'email' => 'Il campo :attribute deve essere un indirizzo email valido.',
|
||||
'ends_with' => 'Il campo :attribute deve terminare con uno dei seguenti: :values.',
|
||||
'enum' => 'Il valore selezionato per :attribute non e valido.',
|
||||
'exists' => 'Il valore selezionato per :attribute non e valido.',
|
||||
'extensions' => 'Il campo :attribute deve avere una delle seguenti estensioni: :values.',
|
||||
'file' => 'Il campo :attribute deve essere un file.',
|
||||
'filled' => 'Il campo :attribute deve avere un valore.',
|
||||
'gt' => [
|
||||
'array' => 'Il campo :attribute deve avere piu di :value elementi.',
|
||||
'file' => 'Il campo :attribute deve essere maggiore di :value kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere maggiore di :value.',
|
||||
'string' => 'Il campo :attribute deve essere maggiore di :value caratteri.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'Il campo :attribute deve avere :value elementi o piu.',
|
||||
'file' => 'Il campo :attribute deve essere maggiore o uguale a :value kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere maggiore o uguale a :value.',
|
||||
'string' => 'Il campo :attribute deve essere maggiore o uguale a :value caratteri.',
|
||||
],
|
||||
'hex_color' => 'Il campo :attribute deve essere un colore esadecimale valido.',
|
||||
'image' => 'Il campo :attribute deve essere una immagine.',
|
||||
'in' => 'Il valore selezionato per :attribute non e valido.',
|
||||
'in_array' => 'Il campo :attribute deve esistere in :other.',
|
||||
'in_array_keys' => 'Il campo :attribute deve contenere almeno una delle seguenti chiavi: :values.',
|
||||
'integer' => 'Il campo :attribute deve essere un numero intero.',
|
||||
'ip' => 'Il campo :attribute deve essere un indirizzo IP valido.',
|
||||
'ipv4' => 'Il campo :attribute deve essere un indirizzo IPv4 valido.',
|
||||
'ipv6' => 'Il campo :attribute deve essere un indirizzo IPv6 valido.',
|
||||
'json' => 'Il campo :attribute deve essere una stringa JSON valida.',
|
||||
'list' => 'Il campo :attribute deve essere una lista.',
|
||||
'lowercase' => 'Il campo :attribute deve essere in minuscolo.',
|
||||
'lt' => [
|
||||
'array' => 'Il campo :attribute deve avere meno di :value elementi.',
|
||||
'file' => 'Il campo :attribute deve essere minore di :value kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere minore di :value.',
|
||||
'string' => 'Il campo :attribute deve essere minore di :value caratteri.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'Il campo :attribute non deve avere piu di :value elementi.',
|
||||
'file' => 'Il campo :attribute deve essere minore o uguale a :value kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere minore o uguale a :value.',
|
||||
'string' => 'Il campo :attribute deve essere minore o uguale a :value caratteri.',
|
||||
],
|
||||
'mac_address' => 'Il campo :attribute deve essere un indirizzo MAC valido.',
|
||||
'max' => [
|
||||
'array' => 'Il campo :attribute non deve avere piu di :max elementi.',
|
||||
'file' => 'Il campo :attribute non deve essere maggiore di :max kilobyte.',
|
||||
'numeric' => 'Il campo :attribute non deve essere maggiore di :max.',
|
||||
'string' => 'Il campo :attribute non deve essere maggiore di :max caratteri.',
|
||||
],
|
||||
'max_digits' => 'Il campo :attribute non deve avere piu di :max cifre.',
|
||||
'mimes' => 'Il campo :attribute deve essere un file di tipo: :values.',
|
||||
'mimetypes' => 'Il campo :attribute deve essere un file di tipo: :values.',
|
||||
'min' => [
|
||||
'array' => 'Il campo :attribute deve avere almeno :min elementi.',
|
||||
'file' => 'Il campo :attribute deve essere almeno :min kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere almeno :min.',
|
||||
'string' => 'Il campo :attribute deve essere almeno :min caratteri.',
|
||||
],
|
||||
'min_digits' => 'Il campo :attribute deve avere almeno :min cifre.',
|
||||
'missing' => 'Il campo :attribute deve essere assente.',
|
||||
'missing_if' => 'Il campo :attribute deve essere assente quando :other e :value.',
|
||||
'missing_unless' => 'Il campo :attribute deve essere assente a meno che :other sia :value.',
|
||||
'missing_with' => 'Il campo :attribute deve essere assente quando :values e presente.',
|
||||
'missing_with_all' => 'Il campo :attribute deve essere assente quando :values sono presenti.',
|
||||
'multiple_of' => 'Il campo :attribute deve essere un multiplo di :value.',
|
||||
'not_in' => 'Il valore selezionato per :attribute non e valido.',
|
||||
'not_regex' => 'Il formato del campo :attribute non e valido.',
|
||||
'numeric' => 'Il campo :attribute deve essere un numero.',
|
||||
'password' => [
|
||||
'letters' => 'Il campo :attribute deve contenere almeno una lettera.',
|
||||
'mixed' => 'Il campo :attribute deve contenere almeno una lettera maiuscola e una minuscola.',
|
||||
'numbers' => 'Il campo :attribute deve contenere almeno un numero.',
|
||||
'symbols' => 'Il campo :attribute deve contenere almeno un simbolo.',
|
||||
'uncompromised' => 'Il valore :attribute e apparso in una violazione di dati. Scegli un altro :attribute.',
|
||||
],
|
||||
'present' => 'Il campo :attribute deve essere presente.',
|
||||
'present_if' => 'Il campo :attribute deve essere presente quando :other e :value.',
|
||||
'present_unless' => 'Il campo :attribute deve essere presente a meno che :other sia :value.',
|
||||
'present_with' => 'Il campo :attribute deve essere presente quando :values e presente.',
|
||||
'present_with_all' => 'Il campo :attribute deve essere presente quando :values sono presenti.',
|
||||
'prohibited' => 'Il campo :attribute e proibito.',
|
||||
'prohibited_if' => 'Il campo :attribute e proibito quando :other e :value.',
|
||||
'prohibited_if_accepted' => 'Il campo :attribute e proibito quando :other e accettato.',
|
||||
'prohibited_if_declined' => 'Il campo :attribute e proibito quando :other e rifiutato.',
|
||||
'prohibited_unless' => 'Il campo :attribute e proibito a meno che :other sia in :values.',
|
||||
'prohibits' => 'Il campo :attribute impedisce la presenza di :other.',
|
||||
'regex' => 'Il formato del campo :attribute non e valido.',
|
||||
'required' => 'Il campo :attribute e obbligatorio.',
|
||||
'required_array_keys' => 'Il campo :attribute deve contenere voci per: :values.',
|
||||
'required_if' => 'Il campo :attribute e obbligatorio quando :other e :value.',
|
||||
'required_if_accepted' => 'Il campo :attribute e obbligatorio quando :other e accettato.',
|
||||
'required_if_declined' => 'Il campo :attribute e obbligatorio quando :other e rifiutato.',
|
||||
'required_unless' => 'Il campo :attribute e obbligatorio a meno che :other sia in :values.',
|
||||
'required_with' => 'Il campo :attribute e obbligatorio quando :values e presente.',
|
||||
'required_with_all' => 'Il campo :attribute e obbligatorio quando :values sono presenti.',
|
||||
'required_without' => 'Il campo :attribute e obbligatorio quando :values non e presente.',
|
||||
'required_without_all' => 'Il campo :attribute e obbligatorio quando nessuno di :values e presente.',
|
||||
'same' => 'Il campo :attribute deve corrispondere a :other.',
|
||||
'size' => [
|
||||
'array' => 'Il campo :attribute deve contenere :size elementi.',
|
||||
'file' => 'Il campo :attribute deve essere di :size kilobyte.',
|
||||
'numeric' => 'Il campo :attribute deve essere :size.',
|
||||
'string' => 'Il campo :attribute deve essere di :size caratteri.',
|
||||
],
|
||||
'starts_with' => 'Il campo :attribute deve iniziare con uno dei seguenti: :values.',
|
||||
'string' => 'Il campo :attribute deve essere una stringa.',
|
||||
'timezone' => 'Il campo :attribute deve essere un fuso orario valido.',
|
||||
'unique' => 'Il valore :attribute e gia stato preso.',
|
||||
'uploaded' => 'Il campo :attribute non e riuscito a caricare.',
|
||||
'uppercase' => 'Il campo :attribute deve essere in maiuscolo.',
|
||||
'url' => 'Il campo :attribute deve essere un URL valido.',
|
||||
'ulid' => 'Il campo :attribute deve essere un ULID valido.',
|
||||
'uuid' => 'Il campo :attribute deve essere un UUID valido.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify custom validation messages for attributes using the
|
||||
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||
| specify a specific custom language line for a given attribute rule.
|
||||
|
|
||||
*/
|
||||
|
||||
'custom' => [
|
||||
'attribute-name' => [
|
||||
'rule-name' => 'custom-message',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Attributes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used to swap our attribute placeholder
|
||||
| with something more reader friendly such as "E-Mail Address" instead
|
||||
| of "email". This simply helps us make our message more expressive.
|
||||
|
|
||||
*/
|
||||
|
||||
'attributes' => [],
|
||||
|
||||
];
|
||||
@ -98,7 +98,6 @@ body {
|
||||
main {
|
||||
@apply overflow-hidden rounded-lg;
|
||||
max-height: calc(100dvh - 1rem);
|
||||
container: main / inline-size;
|
||||
|
||||
/* app */
|
||||
body#app & {
|
||||
@ -187,12 +186,11 @@ main {
|
||||
@apply overflow-y-auto;
|
||||
grid-template-rows: 5rem auto;
|
||||
container: content / inline-size;
|
||||
transition: margin 200ms ease-in-out, width 200ms ease-in-out;
|
||||
|
||||
/* main content title and actions */
|
||||
header {
|
||||
> header {
|
||||
@apply flex flex-row items-center justify-between w-full;
|
||||
@apply bg-white sticky top-0 z-20;
|
||||
@apply bg-white sticky top-0 z-10;
|
||||
|
||||
/* app hedar; if h1 exists it means there's no aside, so force the width from that */
|
||||
h1 {
|
||||
@ -210,19 +208,9 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
/* expand button */
|
||||
button.calendar-expand-toggle {
|
||||
svg {
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* header menu */
|
||||
menu {
|
||||
@apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl;
|
||||
height: calc(100dvh - 0.5rem);
|
||||
width: 33dvw;
|
||||
display: none;
|
||||
@apply flex flex-row items-center justify-end gap-4;
|
||||
}
|
||||
}
|
||||
|
||||
@ -254,46 +242,12 @@ main {
|
||||
|
||||
/* section specific */
|
||||
&#calendar {
|
||||
header {
|
||||
.calendar-expand-toggle {
|
||||
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
|
||||
}
|
||||
|
||||
&:hover .calendar-expand-toggle,
|
||||
&:focus-within .calendar-expand-toggle {
|
||||
@apply opacity-100 pointer-events-auto;
|
||||
}
|
||||
}
|
||||
/* */
|
||||
}
|
||||
&#settings {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
/* expanded */
|
||||
&.expanded {
|
||||
|
||||
button.calendar-expand-toggle {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* container sizing */
|
||||
@container content (width >= 64rem)
|
||||
{
|
||||
main {
|
||||
article {
|
||||
header {
|
||||
menu {
|
||||
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
|
||||
@apply flex flex-row items-center justify-end gap-4 p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* app logo */
|
||||
@ -322,8 +276,8 @@ main {
|
||||
*/
|
||||
|
||||
/* show app nav on the left at md */
|
||||
@media (width >= theme(--breakpoint-md))
|
||||
{
|
||||
@media (width >= theme(--breakpoint-md)) {
|
||||
|
||||
body#app {
|
||||
grid-template-columns: 5rem auto;
|
||||
grid-template-rows: 1fr 0;
|
||||
@ -355,15 +309,13 @@ main {
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
&:has(aside) {
|
||||
grid-template-columns: max(16rem, 20dvw) auto;
|
||||
grid-template-columns: minmax(16rem, 20dvw) auto;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
aside {
|
||||
@apply bg-white overflow-y-auto h-full min-w-48;
|
||||
transition: translate 200ms ease-in-out, visibility 200ms ease-in-out, opacity 200ms ease-in-out;
|
||||
@apply bg-white overflow-y-auto h-full;
|
||||
|
||||
> h1 {
|
||||
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
|
||||
@ -374,20 +326,6 @@ main {
|
||||
article {
|
||||
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
|
||||
}
|
||||
|
||||
/* when the calendar is expanded and aside is gone */
|
||||
&.expanded {
|
||||
|
||||
aside {
|
||||
@apply -translate-x-6 invisible opacity-0;
|
||||
}
|
||||
|
||||
article {
|
||||
@apply pl-6;
|
||||
margin-left: min(-16rem, -20dvw) !important;
|
||||
width: 100cqw !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ button,
|
||||
> label,
|
||||
> button {
|
||||
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
|
||||
@apply border-md border-primary border-l-0 font-medium rounded-none whitespace-nowrap;
|
||||
@apply border-md border-primary border-l-0 font-medium rounded-none;
|
||||
transition: outline 125ms ease-in-out;
|
||||
box-shadow: var(--shadows);
|
||||
--shadows: none;
|
||||
|
||||
@ -131,7 +131,7 @@
|
||||
/* time column */
|
||||
ol.time {
|
||||
@apply grid z-0 pt-4;
|
||||
grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
|
||||
grid-template-rows: repeat(96, var(--row-height));
|
||||
|
||||
time {
|
||||
@apply relative flex items-center justify-end items-start pr-4;
|
||||
@ -149,8 +149,8 @@
|
||||
|
||||
/* event positioning */
|
||||
ol.events {
|
||||
@apply grid py-4;
|
||||
grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
|
||||
@apply grid pt-4;
|
||||
grid-template-rows: repeat(96, var(--row-height));
|
||||
--event-col: 0;
|
||||
--event-row: 0;
|
||||
--event-end: 4;
|
||||
@ -158,7 +158,7 @@
|
||||
--event-fg: var(--color-primary);
|
||||
|
||||
li.event {
|
||||
@apply flex rounded-md relative border border-white;
|
||||
@apply flex rounded-md relative;
|
||||
background-color: var(--event-bg);
|
||||
color: var(--event-fg);
|
||||
grid-row-start: var(--event-row);
|
||||
@ -166,46 +166,39 @@
|
||||
grid-column-start: var(--event-col);
|
||||
grid-column-end: calc(var(--event-col) + 1);
|
||||
top: 0.6rem;
|
||||
transition: translate 100ms ease-in;
|
||||
|
||||
a.event {
|
||||
@apply flex flex-col grow px-3 py-2 gap-2px text-sm;
|
||||
> a {
|
||||
@apply flex flex-col grow px-3 py-2 gap-2px;
|
||||
|
||||
> span {
|
||||
@apply font-semibold leading-none break-all;
|
||||
}
|
||||
|
||||
> time {
|
||||
@apply text-xs;
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
animation: event-hover 125ms ease forwards;
|
||||
@apply -translate-y-2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* bottom controls */
|
||||
footer {
|
||||
@apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary;
|
||||
@apply sticky bottom-0 pt-2 pb-8 z-10;
|
||||
|
||||
a.timezone {
|
||||
@apply text-xs bg-gray-100 rounded px-2 py-1;
|
||||
}
|
||||
|
||||
div.right {
|
||||
@apply flex items-center gap-4 justify-end;
|
||||
}
|
||||
@apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10;
|
||||
@apply sticky bottom-0 pb-8;
|
||||
}
|
||||
|
||||
/* now indicator */
|
||||
.now-indicator {
|
||||
@apply relative pointer-events-none z-10 border-t-3 border-red-600 opacity-90 -ml-2;
|
||||
@apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2;
|
||||
grid-row: var(--now-row);
|
||||
grid-column: var(--now-col-start) / var(--now-col-end);
|
||||
width: calc(100% + 1rem);
|
||||
top: calc(0.6rem + (var(--row-height) * var(--now-offset, 0)));
|
||||
top: 0.6rem;
|
||||
|
||||
&::before {
|
||||
@apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px];
|
||||
@ -215,36 +208,19 @@
|
||||
}
|
||||
|
||||
/* step handling */
|
||||
.calendar.time[data-density="30"] { /* half-hourly */
|
||||
.calendar.time[data-density="30"] {
|
||||
--row-height: 2rem;
|
||||
|
||||
ol.time li:nth-child(2n) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
}
|
||||
.calendar.time[data-density="60"] { /* hourly */
|
||||
.calendar.time[data-density="60"] {
|
||||
--row-height: 1.25rem;
|
||||
|
||||
ol.time li:not(:nth-child(4n + 1)) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
|
||||
&.week {
|
||||
ol.events {
|
||||
li.event[data-span="1"] {
|
||||
a.event > span,
|
||||
a.event > time {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
li.event[data-span="1"],
|
||||
li.event[data-span="2"] {
|
||||
> a.event {
|
||||
@apply flex-row items-center gap-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -394,16 +370,7 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes event-hover {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
z-index: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-2px);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -414,3 +381,4 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,29 +3,26 @@
|
||||
}
|
||||
|
||||
dialog {
|
||||
@apply grid fixed inset-0 m-0 p-0 pointer-events-none;
|
||||
@apply place-items-center bg-transparent opacity-0 invisible;
|
||||
@apply w-full h-full max-w-none max-h-none overflow-clip;
|
||||
@apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none;
|
||||
@apply justify-items-center items-start bg-transparent opacity-0 invisible;
|
||||
@apply w-full h-full max-w-full max-h-full overflow-y-hidden;
|
||||
background-color: rgba(26, 26, 26, 0.75);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
/*(grid-template-rows: minmax(20dvh, 2rem) 1fr; */
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: auto;
|
||||
grid-template-rows: minmax(20dvh, 2rem) 1fr;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0,0,.2,1),
|
||||
opacity 150ms cubic-bezier(0,0,.2,1),
|
||||
visibility 150ms cubic-bezier(0,0,.2,1);
|
||||
z-index: 100;
|
||||
|
||||
#modal {
|
||||
@apply relative rounded-xl bg-white border-gray-200 p-0;
|
||||
@apply flex flex-col items-start col-start-1 translate-y-4;
|
||||
@apply relative rounded-lg bg-white border-gray-200 p-0;
|
||||
@apply flex flex-col items-start col-start-1 row-start-2 translate-y-4;
|
||||
@apply overscroll-contain overflow-y-auto;
|
||||
max-height: calc(100vh - 5em);
|
||||
width: 91.666667%;
|
||||
max-width: 36rem;
|
||||
transition: all 150ms cubic-bezier(0,0,.2,1);
|
||||
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
||||
box-shadow: #00000040 0 1.5rem 4rem -0.5rem;
|
||||
|
||||
> .close-modal {
|
||||
@apply block absolute top-4 right-4;
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
import './bootstrap';
|
||||
import htmx from 'htmx.org';
|
||||
|
||||
const SELECTORS = {
|
||||
calendarToggle: '.calendar-toggle',
|
||||
calendarViewForm: '#calendar-view',
|
||||
calendarExpandToggle: '[data-calendar-expand]',
|
||||
colorPicker: '[data-colorpicker]',
|
||||
colorPickerColor: '[data-colorpicker-color]',
|
||||
colorPickerHex: '[data-colorpicker-hex]',
|
||||
colorPickerRandom: '[data-colorpicker-random]',
|
||||
};
|
||||
|
||||
/**
|
||||
* htmx/global
|
||||
*/
|
||||
@ -29,43 +19,34 @@ document.addEventListener('htmx:configRequest', (evt) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* calendar ui
|
||||
* calendar toggle
|
||||
* progressive enhancement on html form with no js
|
||||
*/
|
||||
document.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
document.addEventListener('change', event => {
|
||||
const checkbox = event.target;
|
||||
|
||||
if (target?.matches(SELECTORS.calendarToggle)) {
|
||||
const slug = target.value;
|
||||
const show = target.checked;
|
||||
// ignore anything that isn’t one of our checkboxes
|
||||
if (!checkbox.matches('.calendar-toggle')) return;
|
||||
|
||||
document
|
||||
.querySelectorAll(`[data-calendar="${slug}"]`)
|
||||
.forEach(el => el.classList.toggle('hidden', !show));
|
||||
return;
|
||||
}
|
||||
const slug = checkbox.value;
|
||||
const show = checkbox.checked;
|
||||
|
||||
const form = target?.form;
|
||||
if (!form || form.id !== 'calendar-view') return;
|
||||
if (target.name !== 'view') return;
|
||||
|
||||
form.requestSubmit();
|
||||
// toggle .hidden on every matching event element
|
||||
document
|
||||
.querySelectorAll(`[data-calendar="${slug}"]`)
|
||||
.forEach(el => el.classList.toggle('hidden', !show));
|
||||
});
|
||||
|
||||
/**
|
||||
* calendar sidebar expand toggle
|
||||
* calendar view picker
|
||||
* progressive enhancement on html form with no js
|
||||
*/
|
||||
document.addEventListener('click', (event) => {
|
||||
const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
|
||||
if (!toggle) return;
|
||||
document.addEventListener('change', (e) => {
|
||||
const form = e.target?.form;
|
||||
if (!form || form.id !== 'calendar-view') return;
|
||||
if (e.target.name !== 'view') return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const main = toggle.closest('main');
|
||||
if (!main) return;
|
||||
|
||||
const isExpanded = main.classList.toggle('expanded');
|
||||
toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false');
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
/**
|
||||
@ -90,9 +71,9 @@ function initColorPickers(root = document) {
|
||||
if (el.__colorpickerWired) return;
|
||||
el.__colorpickerWired = true;
|
||||
|
||||
const color = el.querySelector(SELECTORS.colorPickerColor);
|
||||
const hex = el.querySelector(SELECTORS.colorPickerHex);
|
||||
const btn = el.querySelector(SELECTORS.colorPickerRandom);
|
||||
const color = el.querySelector('[data-colorpicker-color]');
|
||||
const hex = el.querySelector('[data-colorpicker-hex]');
|
||||
const btn = el.querySelector('[data-colorpicker-random]');
|
||||
|
||||
if (!color || !hex) return;
|
||||
|
||||
@ -156,15 +137,11 @@ function initColorPickers(root = document) {
|
||||
}
|
||||
};
|
||||
|
||||
root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
|
||||
}
|
||||
|
||||
function initUI() {
|
||||
initColorPickers();
|
||||
root.querySelectorAll('[data-colorpicker]').forEach(wire);
|
||||
}
|
||||
|
||||
// initial bind
|
||||
document.addEventListener('DOMContentLoaded', initUI);
|
||||
document.addEventListener('DOMContentLoaded', () => initColorPickers());
|
||||
|
||||
// rebind in htmx for swapped content
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg>
|
||||
|
Before Width: | Height: | Size: 323 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg>
|
||||
|
Before Width: | Height: | Size: 327 B |
38
resources/views/account/edit.blade.php
Normal file
38
resources/views/account/edit.blade.php
Normal file
@ -0,0 +1,38 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Profile') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('account.partials.update-profile-information-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('account.partials.addresses-form', [
|
||||
'home' => $home ?? null,
|
||||
'billing' => $billing ?? null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('account.partials.update-password-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('account.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@ -74,7 +74,6 @@
|
||||
:view="$view"
|
||||
:density="$density"
|
||||
:headers="$mini_headers"
|
||||
:daytime_hours="$daytime_hours"
|
||||
class="aside-inset"
|
||||
/>
|
||||
</x-slot>
|
||||
@ -85,14 +84,6 @@
|
||||
@if(!empty($header['span']))
|
||||
<span>{{ $header['span'] }}</span>
|
||||
@endif
|
||||
<button
|
||||
type="button"
|
||||
class="button button--icon button--sm calendar-expand-toggle"
|
||||
data-calendar-expand
|
||||
aria-label="{{ __('calendar.toggle_sidebar') }}"
|
||||
>
|
||||
<x-icon-collapse />
|
||||
</button>
|
||||
</h2>
|
||||
<menu>
|
||||
<li>
|
||||
@ -114,7 +105,6 @@
|
||||
{{-- persist values from other forms --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||
|
||||
<nav class="button-group button-group--primary">
|
||||
<x-button.group-button
|
||||
@ -158,23 +148,10 @@
|
||||
|
||||
{{-- persist data from density form --}}
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||
<x-button.group-input value="day" :active="$view === 'day'">
|
||||
<span class="lg:hidden">1</span>
|
||||
<span class="hidden lg:inline">Day</span>
|
||||
</x-button.group-input>
|
||||
<x-button.group-input value="week" :active="$view === 'week'">
|
||||
<span class="lg:hidden">7</span>
|
||||
<span class="hidden lg:inline">Week</span>
|
||||
</x-button.group-input>
|
||||
<x-button.group-input value="month" :active="$view === 'month'">
|
||||
<span class="lg:hidden">31</span>
|
||||
<span class="hidden lg:inline">Month</span>
|
||||
</x-button.group-input>
|
||||
<x-button.group-input value="four" :active="$view === 'four'">
|
||||
<span class="lg:hidden">4</span>
|
||||
<span class="hidden lg:inline">4-day</span>
|
||||
</x-button.group-input>
|
||||
<x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input>
|
||||
<x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input>
|
||||
<x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input>
|
||||
<x-button.group-input value="four" :active="$view === 'four'">3-Up</x-button.group-input>
|
||||
<noscript><button type="submit" class="button">Apply</button></noscript>
|
||||
</form>
|
||||
<li>
|
||||
@ -199,8 +176,6 @@
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:weekstart="$week_start"
|
||||
:daytime_hours="$daytime_hours"
|
||||
:timezone="$timezone"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@ -215,7 +190,6 @@
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:daytime_hours="$daytime_hours"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@ -230,7 +204,6 @@
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:daytime_hours="$daytime_hours"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
|
||||
@ -9,12 +9,11 @@
|
||||
'active' => [],
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
style="--now-row: {{ $now['row'] }}; --now-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }}; --grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||
style="--now-row: {{ $now['row'] }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -40,7 +39,6 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<x-calendar.time.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="day" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -9,17 +9,14 @@
|
||||
'active' => [],
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||
--now-col-end: {{ (int) $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -45,7 +42,6 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="four" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
'class' => '',
|
||||
'density' => [],
|
||||
'headers' => [],
|
||||
'daytime_hours' => [],
|
||||
])
|
||||
|
||||
<section id="mini" class="mini mini--month {{ $class }}">
|
||||
@ -23,7 +22,6 @@
|
||||
{{-- preserve main calendar context for full-reload fallback --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="date" value="{{ request('date') }}">
|
||||
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||
{{-- nav buttons --}}
|
||||
<button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
|
||||
<x-icon-chevron-left />
|
||||
@ -53,7 +51,6 @@
|
||||
{{-- stay on the same view (month/week/etc --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||
|
||||
@foreach ($mini['days'] as $day)
|
||||
<button
|
||||
|
||||
@ -19,14 +19,10 @@
|
||||
@php
|
||||
$event = $events[$eventId];
|
||||
$color = $event['color'] ?? '#999';
|
||||
$showParams = [$event['calendar_slug'], $event['id']];
|
||||
if (!empty($event['occurrence'])) {
|
||||
$showParams['occurrence'] = $event['occurrence'];
|
||||
}
|
||||
@endphp
|
||||
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
|
||||
href="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-get="{{ route('calendar.event.show', $showParams) }}"
|
||||
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
@props([
|
||||
'daytime_hours' => [],
|
||||
'view' => 'day',
|
||||
'density' => [],
|
||||
])
|
||||
|
||||
@php
|
||||
$enabled = (int) ($daytime_hours['enabled'] ?? 0) === 1;
|
||||
@endphp
|
||||
|
||||
<form id="calendar-daytime-hours"
|
||||
method="get"
|
||||
class="inline-flex items-center gap-2 text-sm"
|
||||
action="{{ route('calendar.index') }}"
|
||||
hx-get="{{ route('calendar.index') }}"
|
||||
hx-target="#calendar"
|
||||
hx-select="#calendar"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change"
|
||||
hx-include="#calendar-toggles">
|
||||
|
||||
{{-- preserve current view and anchor date --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="date" value="{{ $density['anchor'] ?? request('date') }}">
|
||||
<input type="hidden" name="density" value="{{ $density['step'] ?? 30 }}">
|
||||
|
||||
{{-- unchecked checkboxes don't submit a value --}}
|
||||
<input type="hidden" name="daytime_hours" value="0">
|
||||
<x-input.checkbox-label
|
||||
name="daytime_hours"
|
||||
value="1"
|
||||
label="{{ __('Daytime hours') }}"
|
||||
:checked="$enabled"
|
||||
/>
|
||||
|
||||
<noscript>
|
||||
<button type="submit" class="button">{{ __('Apply') }}</button>
|
||||
</noscript>
|
||||
</form>
|
||||
@ -1,7 +1,6 @@
|
||||
@props([
|
||||
'density' => [],
|
||||
'view' => 'day',
|
||||
'daytime_hours' => [],
|
||||
])
|
||||
|
||||
<form id="calendar-density"
|
||||
@ -19,7 +18,6 @@
|
||||
{{-- preserve current view and anchor date --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="date" value="{{ $density['anchor'] }}">
|
||||
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||
|
||||
<x-button.group-input value="15" name="density" :active="(int)($density['step'] ?? 30) === 15">15m</x-button.group-input>
|
||||
<x-button.group-input value="30" name="density" :active="(int)($density['step'] ?? 30) === 30">30m</x-button.group-input>
|
||||
|
||||
@ -3,11 +3,10 @@
|
||||
])
|
||||
|
||||
<li class="event"
|
||||
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
|
||||
data-event-id="{{ $event['id'] }}"
|
||||
data-calendar-id="{{ $event['calendar_slug'] }}"
|
||||
data-start="{{ $event['start_ui'] }}"
|
||||
data-duration="{{ $event['duration'] }}"
|
||||
data-span="{{ $event['row_span'] }}"
|
||||
style="
|
||||
--event-row: {{ $event['start_row'] }};
|
||||
--event-end: {{ $event['end_row'] }};
|
||||
@ -15,15 +14,9 @@
|
||||
--event-bg: {{ $event['color'] }};
|
||||
--event-fg: {{ $event['color_fg'] }};
|
||||
">
|
||||
@php
|
||||
$showParams = [$event['calendar_slug'], $event['id']];
|
||||
if (!empty($event['occurrence'])) {
|
||||
$showParams['occurrence'] = $event['occurrence'];
|
||||
}
|
||||
@endphp
|
||||
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
|
||||
href="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-get="{{ route('calendar.event.show', $showParams) }}"
|
||||
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@ -10,18 +10,14 @@
|
||||
'density' => '30',
|
||||
'weekstart' => 0,
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
'timezone' => 'UTC',
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||
--now-col-end: {{ (int) $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -47,12 +43,6 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<div class="left">
|
||||
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
|
||||
</div>
|
||||
<div class="right">
|
||||
<x-calendar.time.daytime-hours view="week" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="week" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
</div>
|
||||
<x-calendar.time.density view="week" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -105,22 +105,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Recurrence (advanced) --}}
|
||||
<details class="mb-6">
|
||||
<summary class="cursor-pointer text-sm text-gray-600">
|
||||
{{ __('Repeat (advanced)') }}
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<x-input-label for="rrule" :value="__('RRULE')" />
|
||||
<x-text-input id="rrule" name="rrule" type="text" class="mt-1 block w-full"
|
||||
:value="old('rrule', $rrule ?? '')" />
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Example: <code>FREQ=WEEKLY;BYDAY=MO,WE</code>
|
||||
</p>
|
||||
<x-input.error class="mt-2" :messages="$errors->get('rrule')" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="flex justify-end space-x-2">
|
||||
<a href="{{ route('calendar.show', $calendar) }}"
|
||||
|
||||
@ -1,147 +1,26 @@
|
||||
@php
|
||||
$meta = $event->meta;
|
||||
$title = $meta->title ?? '(no title)';
|
||||
$allDay = (bool) ($meta->all_day ?? false);
|
||||
$calendarName = $calendar->displayname ?? __('common.calendar');
|
||||
$calendarColor = $calendar->meta_color ?? $calendar->calendarcolor ?? default_calendar_color();
|
||||
$rrule = $meta?->extra['rrule'] ?? null;
|
||||
$tzid = $meta?->extra['tzid'] ?? $tz;
|
||||
$locationLabel = $meta?->location_label ?? '';
|
||||
$hasLocation = trim((string) $locationLabel) !== '';
|
||||
$venue = $meta?->venue;
|
||||
$addressLine1 = $venue?->street;
|
||||
$addressLine2 = trim(implode(', ', array_filter([
|
||||
$venue?->city,
|
||||
$venue?->state,
|
||||
$venue?->postal,
|
||||
])));
|
||||
$addressLine3 = $venue?->country;
|
||||
@endphp
|
||||
|
||||
<x-modal.content>
|
||||
<x-modal.title>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-block h-3 w-3 rounded-full" style="background: {{ $calendarColor }};"></span>
|
||||
<span>{{ $title }}</span>
|
||||
</div>
|
||||
{{ $event->meta->title ?? '(no title)' }}
|
||||
</x-modal.title>
|
||||
<x-modal.body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<section class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
|
||||
@if ($allDay)
|
||||
<p class="text-lg text-gray-900">
|
||||
{{ $start->format('l, F j, Y') }}
|
||||
@unless ($start->isSameDay($end))
|
||||
–
|
||||
{{ $end->format('l, F j, Y') }}
|
||||
@endunless
|
||||
<span class="text-sm text-gray-500">({{ __('calendar.event.all_day') }})</span>
|
||||
</p>
|
||||
@else
|
||||
<p class="text-lg text-gray-900">
|
||||
{{ $start->format('l, F j, Y · g:i A') }}
|
||||
@unless ($start->equalTo($end))
|
||||
–
|
||||
{{ $end->isSameDay($start)
|
||||
? $end->format('g:i A')
|
||||
: $end->format('l, F j, Y · g:i A') }}
|
||||
@endunless
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-sm text-gray-500">{{ __('common.timezone') }}: {{ $tzid }}</p>
|
||||
</section>
|
||||
<p class="text-gray-700">
|
||||
{{ $start->format('l, F j, Y · g:i A') }}
|
||||
@unless ($start->equalTo($end))
|
||||
–
|
||||
{{ $end->isSameDay($start)
|
||||
? $end->format('g:i A')
|
||||
: $end->format('l, F j, Y · g:i A') }}
|
||||
@endunless
|
||||
</p>
|
||||
|
||||
<section class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('common.calendar') }}</p>
|
||||
<p class="text-gray-900">{{ $calendarName }}</p>
|
||||
</section>
|
||||
@if ($event->meta->location)
|
||||
<p><strong>Where:</strong> {{ $event->meta->location_label }}</p>
|
||||
@endif
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.location') }}</p>
|
||||
@if ($hasLocation)
|
||||
<p class="text-gray-900">{{ $locationLabel }}</p>
|
||||
@if ($addressLine1 || $addressLine2 || $addressLine3)
|
||||
<div class="text-sm text-gray-600">
|
||||
@if ($addressLine1)
|
||||
<div>{{ $addressLine1 }}</div>
|
||||
@endif
|
||||
@if ($addressLine2)
|
||||
<div>{{ $addressLine2 }}</div>
|
||||
@endif
|
||||
@if ($addressLine3)
|
||||
<div>{{ $addressLine3 }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="mt-2 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-500">
|
||||
{{ __('calendar.event.map_coming') }}
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.details') }}</p>
|
||||
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700">
|
||||
<div>
|
||||
<span class="text-gray-500">{{ __('calendar.event.repeats') }}:</span>
|
||||
@if ($rrule)
|
||||
<span class="ml-1 font-mono text-gray-800">{{ $rrule }}</span>
|
||||
@else
|
||||
<span class="ml-1 text-gray-500">{{ __('calendar.event.does_not_repeat') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{{ __('calendar.event.category') }}:</span>
|
||||
<span class="ml-1">{{ $meta->category ?? __('calendar.event.none') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{{ __('calendar.event.visibility') }}:</span>
|
||||
<span class="ml-1">{{ ($meta->is_private ?? false) ? __('calendar.event.private') : __('calendar.event.default') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">{{ __('calendar.event.all_day_handling') }}:</span>
|
||||
<span class="ml-1">
|
||||
{{ $allDay ? __('calendar.event.all_day') : __('calendar.event.timed') }}
|
||||
<span class="text-gray-400">· {{ __('calendar.event.all_day_coming') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.alerts') }}</p>
|
||||
@if (!is_null($meta->reminder_minutes))
|
||||
<p class="text-sm text-gray-700">
|
||||
{{ __('calendar.event.reminder') }}: {{ $meta->reminder_minutes }} {{ __('calendar.event.minutes_before') }}
|
||||
</p>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.alerts_coming') }}</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.invitees') }}</p>
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.invitees_coming') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.attachments') }}</p>
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.attachments_coming') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.notes') }}</p>
|
||||
@if ($meta->description)
|
||||
<div class="prose prose-sm max-w-none text-gray-800">
|
||||
{!! Str::markdown(nl2br(e($meta->description))) !!}
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.no_description') }}</p>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
@if ($event->meta->description)
|
||||
<p>
|
||||
{!! Str::markdown(nl2br(e($event->meta->description))) !!}
|
||||
</p>
|
||||
@endif
|
||||
</x-modal.body>
|
||||
</x-modal.content>
|
||||
|
||||
@ -28,6 +28,9 @@
|
||||
|
||||
<!-- bottom -->
|
||||
<section class="bottom">
|
||||
<x-button.icon type="anchor" :href="route('settings')">
|
||||
<x-icon-settings class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
<x-button.icon type="anchor" :href="route('account.index')">
|
||||
<x-icon-user-circle class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
|
||||
@ -8,7 +8,7 @@ test('password can be updated', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/account/password')
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
@ -17,7 +17,7 @@ test('password can be updated', function () {
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/account/password');
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
});
|
||||
@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/account/password')
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () {
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/account/password');
|
||||
->assertRedirect('/profile');
|
||||
});
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function createTestUser(array $overrides = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'displayname' => 'Test User',
|
||||
'email' => 'test+'.Str::uuid().'@example.com',
|
||||
'timezone' => 'America/New_York',
|
||||
'password' => Hash::make('password'),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
test('calendar index handles invalid date without error', function () {
|
||||
$user = createTestUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('calendar.index', ['date' => 'not-a-date']))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('daytime_hours persists to user settings', function () {
|
||||
$user = createTestUser();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('calendar.index', ['daytime_hours' => 1]))
|
||||
->assertOk();
|
||||
|
||||
$value = DB::table('user_settings')
|
||||
->where('user_id', $user->id)
|
||||
->where('key', 'calendar.daytime_hours')
|
||||
->value('value');
|
||||
|
||||
expect($value)->toBe('1');
|
||||
});
|
||||
@ -2,35 +2,32 @@
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('account info page is displayed', function () {
|
||||
test('profile page is displayed', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/account/info');
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('account information can be updated', function () {
|
||||
test('profile information can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/account/info', [
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/account/info');
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test', $user->firstname);
|
||||
$this->assertSame('User', $user->lastname);
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
@ -41,15 +38,14 @@ test('email verification status is unchanged when the email address is unchanged
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/account/info', [
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/account/info');
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
});
|
||||
@ -59,13 +55,13 @@ test('user can delete their account', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/account', [
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/dashboard');
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
@ -76,14 +72,14 @@ test('correct password must be provided to delete account', function () {
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/account/delete/confirm')
|
||||
->delete('/account', [
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('userDeletion', 'password')
|
||||
->assertRedirect('/account/delete/confirm');
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Calendar\CalendarRangeResolver;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
test('safeDate falls back to default on invalid date', function () {
|
||||
$resolver = new CalendarRangeResolver();
|
||||
$tz = 'America/New_York';
|
||||
|
||||
$date = $resolver->safeDate('nope', $tz, '2026-02-04');
|
||||
|
||||
expect($date->toDateString())->toBe('2026-02-04');
|
||||
});
|
||||
|
||||
test('resolveRange handles four-day view', function () {
|
||||
$resolver = new CalendarRangeResolver();
|
||||
$tz = 'America/New_York';
|
||||
|
||||
$request = Request::create('/calendar', 'GET', [
|
||||
'view' => 'four',
|
||||
'date' => '2026-02-04',
|
||||
]);
|
||||
|
||||
[$view, $range] = $resolver->resolveRange(
|
||||
$request,
|
||||
$tz,
|
||||
Carbon::SUNDAY,
|
||||
Carbon::SATURDAY,
|
||||
'month',
|
||||
'2026-02-04'
|
||||
);
|
||||
|
||||
expect($view)->toBe('four');
|
||||
expect($range['start']->toDateString())->toBe('2026-02-04');
|
||||
expect($range['end']->toDateString())->toBe('2026-02-07');
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Calendar\CalendarViewBuilder;
|
||||
use Carbon\Carbon;
|
||||
|
||||
test('timeSlots returns full day when no daytime hours', function () {
|
||||
$builder = new CalendarViewBuilder();
|
||||
$tz = 'America/New_York';
|
||||
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
|
||||
|
||||
$slots = $builder->timeSlots($dayStart, $tz, '12');
|
||||
|
||||
expect($slots)->toHaveCount(96);
|
||||
expect($slots[0]['key'])->toBe('00:00');
|
||||
expect($slots[95]['key'])->toBe('23:45');
|
||||
});
|
||||
|
||||
test('timeSlots respects daytime hours', function () {
|
||||
$builder = new CalendarViewBuilder();
|
||||
$tz = 'America/New_York';
|
||||
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
|
||||
|
||||
$slots = $builder->timeSlots($dayStart, $tz, '12', ['start' => 8, 'end' => 18]);
|
||||
|
||||
expect($slots)->toHaveCount(40);
|
||||
expect($slots[0]['key'])->toBe('08:00');
|
||||
expect($slots[39]['key'])->toBe('17:45');
|
||||
});
|
||||
|
||||
test('nowIndicator uses minute-accurate offset in daytime hours', function () {
|
||||
$builder = new CalendarViewBuilder();
|
||||
$tz = 'America/New_York';
|
||||
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 4, 11, 37, 0, $tz));
|
||||
|
||||
$range = [
|
||||
'start' => Carbon::create(2026, 2, 4, 0, 0, 0, $tz),
|
||||
'end' => Carbon::create(2026, 2, 4, 23, 59, 59, $tz),
|
||||
];
|
||||
|
||||
$now = $builder->nowIndicator('day', $range, $tz, 15, 1, ['start' => 8, 'end' => 18]);
|
||||
|
||||
expect($now['show'])->toBeTrue();
|
||||
expect($now['row'])->toBe(15);
|
||||
expect($now['offset'])->toBeGreaterThan(0.46);
|
||||
expect($now['offset'])->toBeLessThan(0.47);
|
||||
expect($now['col_start'])->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
5
tests/Unit/ExampleTest.php
Normal file
5
tests/Unit/ExampleTest.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
test('that true is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user