diff --git a/.env.example b/.env.example index c0660ea..48b6515 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Services +MAPBOX_API_KEY= +OPENWEATHER_API_KEY= diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index cde1494..6863968 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,4 @@ 24b8f32bcf7e24f999bd8763bc9baca607fad516 +62f533191a8f08fb724b207b4e3f53f9ab2b35bb +e42a663c237250cc6e17eed50a7e3802d35e6a34 +7dc4a38cd79c5cb74f6d0ababf1ded730ab1d030 diff --git a/app/Actions/Calendars/FetchCalendarEvents.php b/app/Actions/Calendars/FetchCalendarEvents.php new file mode 100644 index 0000000..d99e52c --- /dev/null +++ b/app/Actions/Calendars/FetchCalendarEvents.php @@ -0,0 +1,167 @@ + + */ + public function handle(CalendarFeed $feed, int $days = 30, ?CarbonImmutable $from = null): Collection + { + $body = Cache::remember( + key: "calendar-feed:{$feed->id}:" . md5($feed->url), + ttl: now()->addMinutes(15), + callback: fn () => Http::withHeaders([ + 'Accept' => 'text/calendar,text/plain,*/*', + 'User-Agent' => 'SunnyCalendar/1.0', + ])->timeout(10)->get($feed->url)->throw()->body(), + ); + + return $this->parse($body, $feed, $from ?? CarbonImmutable::now($this->timezoneName($feed)), $days); + } + + /** + * @return Collection + */ + public function parse(string $ics, CalendarFeed $feed, CarbonImmutable $from, int $days = 30): Collection + { + $timezone = new DateTimeZone($this->timezoneName($feed)); + $from = $from->setTimezone($timezone); + $until = $from->addDays($days); + $calendar = Reader::read($ics); + $expandedCalendar = $calendar->expand($from, $until, $timezone); + + return collect($expandedCalendar->select('VEVENT')) + ->filter(fn ($event) => $event instanceof VEvent && isset($event->DTSTART)) + ->map(fn (VEvent $event) => $this->eventData($event, $feed, $timezone)) + ->sortBy('starts_at') + ->values(); + } + + /** + * @return array{ + * feed_id: int, + * feed_name: string, + * feed_color: string, + * title: string, + * location: string|null, + * starts_at: CarbonImmutable, + * ends_at: CarbonImmutable|null, + * all_day: bool, + * response_status: string|null + * } + */ + private function eventData(VEvent $event, CalendarFeed $feed, DateTimeZone $timezone): array + { + $responseStatus = $this->responseStatus($event, $feed); + + return [ + 'feed_id' => $feed->id, + 'feed_name' => $feed->name, + 'feed_color' => $feed->color, + 'title' => blank((string) ($event->SUMMARY ?? '')) ? __('Untitled event') : (string) $event->SUMMARY, + 'location' => blank((string) ($event->LOCATION ?? '')) ? null : (string) $event->LOCATION, + 'starts_at' => $this->carbon($event->DTSTART->getDateTime($timezone), $timezone), + 'ends_at' => $this->endDate($event, $timezone), + 'all_day' => ! $event->DTSTART->hasTime(), + 'response_status' => $responseStatus, + ]; + } + + private function endDate(VEvent $event, DateTimeZone $timezone): ?CarbonImmutable + { + if (isset($event->DTEND)) { + return $this->carbon($event->DTEND->getDateTime($timezone), $timezone); + } + + if (isset($event->DURATION)) { + return $this->carbon($event->DTSTART->getDateTime($timezone), $timezone) + ->add(DateTimeParser::parseDuration((string) $event->DURATION)); + } + + return null; + } + + private function carbon(DateTimeInterface $dateTime, DateTimeZone $timezone): CarbonImmutable + { + return CarbonImmutable::instance($dateTime)->setTimezone($timezone); + } + + private function responseStatus(VEvent $event, CalendarFeed $feed): ?string + { + if (! isset($event->ATTENDEE)) { + return null; + } + + $emails = $this->teamMemberEmails($feed); + + if ($emails->isEmpty()) { + return null; + } + + foreach ($event->ATTENDEE as $attendee) { + $attendeeEmail = mb_strtolower(preg_replace('/^mailto:/i', '', (string) $attendee->getValue())); + + if ($emails->doesntContain($attendeeEmail)) { + continue; + } + + return isset($attendee['PARTSTAT']) + ? strtoupper((string) $attendee['PARTSTAT']) + : null; + } + + return null; + } + + /** @return Collection */ + private function teamMemberEmails(CalendarFeed $feed): Collection + { + $feed->loadMissing('team.members'); + + return $feed->team->members + ->pluck('email') + ->map(fn (string $email): string => mb_strtolower($email)) + ->filter() + ->values(); + } + + private function timezoneName(CalendarFeed $feed): string + { + return $feed->team?->timezone ?: 'America/Chicago'; + } +} diff --git a/app/Actions/Kiosk/CreateFeed.php b/app/Actions/Kiosk/CreateFeed.php new file mode 100644 index 0000000..58cc9c5 --- /dev/null +++ b/app/Actions/Kiosk/CreateFeed.php @@ -0,0 +1,17 @@ + $data */ + public function handle(Team $team, array $data): CalendarFeed + { + return $team->calendarFeeds()->create($data); + } +} diff --git a/app/Actions/Kiosk/UpdateFeed.php b/app/Actions/Kiosk/UpdateFeed.php new file mode 100644 index 0000000..4e201f2 --- /dev/null +++ b/app/Actions/Kiosk/UpdateFeed.php @@ -0,0 +1,18 @@ + $data */ + public function handle(CalendarFeed $feed, array $data): CalendarFeed + { + $feed->update($data); + + return $feed; + } +} diff --git a/app/Enums/CalendarColor.php b/app/Enums/CalendarColor.php new file mode 100644 index 0000000..8ef9c82 --- /dev/null +++ b/app/Enums/CalendarColor.php @@ -0,0 +1,17 @@ + config('services.openweather.key'), + 'units' => 'imperial', + ]; + } +} diff --git a/app/Http/Integrations/OpenWeather/Requests/OneCall.php b/app/Http/Integrations/OpenWeather/Requests/OneCall.php new file mode 100644 index 0000000..37f3010 --- /dev/null +++ b/app/Http/Integrations/OpenWeather/Requests/OneCall.php @@ -0,0 +1,33 @@ + $this->lat, + 'lon' => $this->lon, + 'exclude' => $this->exclude, + ]); + } +} diff --git a/app/Http/Middleware/RestrictKioskSession.php b/app/Http/Middleware/RestrictKioskSession.php new file mode 100644 index 0000000..25d7d44 --- /dev/null +++ b/app/Http/Middleware/RestrictKioskSession.php @@ -0,0 +1,43 @@ +session()->has('kiosk_device_id')) { + return $next($request); + } + + if ($this->isKioskPath($request)) { + return $next($request); + } + + abort(403, 'This kiosk session is restricted to kiosk pages.'); + } + + protected function isKioskPath(Request $request): bool + { + $path = '/' . ltrim($request->path(), '/'); + + if (str_starts_with($path, '/livewire/') || str_starts_with($path, '/livewire-')) { + return true; + } + + if ($path === '/kiosk' || str_starts_with($path, '/kiosk/')) { + return true; + } + + $segments = explode('/', trim($path, '/')); + + return count($segments) >= 2 && $segments[1] === 'kiosk'; + } +} diff --git a/app/Livewire/Forms/Kiosk/CalendarFeedForm.php b/app/Livewire/Forms/Kiosk/CalendarFeedForm.php new file mode 100644 index 0000000..c37604b --- /dev/null +++ b/app/Livewire/Forms/Kiosk/CalendarFeedForm.php @@ -0,0 +1,57 @@ +value; + + public function load(CalendarFeed $feed): void + { + $this->fill([ + 'editingFeed' => $feed, + 'name' => $feed->name, + 'url' => $feed->url, + 'color' => $feed->color->value, + ]); + } + + public function save(): void + { + $data = $this->validate(); + + if ($this->editingFeed) { + (new UpdateFeed)->handle($this->editingFeed, $data); + } else { + (new CreateFeed)->handle(Auth::user()->currentTeam, $data); + } + + $this->reset(); + } + + /** @return array */ + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'string', 'url:http,https'], + 'color' => ['required', Rule::enum(CalendarColor::class)], + ]; + } +} diff --git a/app/Livewire/Forms/Kiosk/SettingsForm.php b/app/Livewire/Forms/Kiosk/SettingsForm.php new file mode 100644 index 0000000..8fbc702 --- /dev/null +++ b/app/Livewire/Forms/Kiosk/SettingsForm.php @@ -0,0 +1,49 @@ + 'required|array', + 'address.*' => 'required', + ])] + public array $address = [ + 'address' => '', + 'city' => '', + 'state' => '', + 'zip' => '', + 'lat' => '', + 'long' => '', + ]; + + public function load(Team $team) + { + $this->editingTeam = $team; + $this->timezone = $team->timezone; + $this->week_start = $team->week_start; + $this->address = $team->address ?? $this->address; + } + + public function save() + { + $data = $this->validate(); + + $this->editingTeam->update($data); + } +} diff --git a/app/Models/CalendarFeed.php b/app/Models/CalendarFeed.php new file mode 100644 index 0000000..0535bd0 --- /dev/null +++ b/app/Models/CalendarFeed.php @@ -0,0 +1,33 @@ + */ + use HasFactory; + + /** @return BelongsTo */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'color' => CalendarColor::class, + ]; + } +} diff --git a/app/Models/KioskDevice.php b/app/Models/KioskDevice.php new file mode 100644 index 0000000..6180c6c --- /dev/null +++ b/app/Models/KioskDevice.php @@ -0,0 +1,89 @@ + */ + use HasFactory; + + public const PAIRING_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; + + public const PAIRING_CODE_LENGTH = 8; + + public const PAIRING_TTL_MINUTES = 15; + + public static function generatePairingCode(): string + { + $code = ''; + + for ($i = 0; $i < self::PAIRING_CODE_LENGTH; $i++) { + $code .= self::PAIRING_ALPHABET[random_int(0, strlen(self::PAIRING_ALPHABET) - 1)]; + } + + return $code; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return BelongsTo */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function isPaired(): bool + { + return $this->paired_at !== null; + } + + /** @param Builder $query */ + #[Scope] + protected function pending(Builder $query): void + { + $query->whereNull('paired_at')->where('expires_at', '>', now()); + } + + /** @param Builder $query */ + #[Scope] + protected function paired(Builder $query): void + { + $query->whereNotNull('paired_at'); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'paired_at' => 'datetime', + 'expires_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index a2ca519..eb7a7eb 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; -#[Fillable(['name', 'slug', 'is_personal'])] +#[Fillable(['name', 'slug', 'is_personal', 'address', 'timezone', 'week_start'])] class Team extends Model { /** @use HasFactory */ @@ -38,6 +38,12 @@ protected static function boot(): void }); } + /** @return HasMany */ + public function calendarFeeds(): HasMany + { + return $this->hasMany(CalendarFeed::class); + } + public function owner(): ?Model { return $this->members() @@ -100,6 +106,7 @@ protected function casts(): array { return [ 'is_personal' => 'boolean', + 'address' => 'array', ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 431eb23..a1862e0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -18,7 +18,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['name', 'email', 'password', 'current_team_id'])] +#[Fillable(['name', 'email', 'password', 'current_team_id', 'timezone'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements PasskeyUser { diff --git a/app/Policies/CalendarFeedPolicy.php b/app/Policies/CalendarFeedPolicy.php new file mode 100644 index 0000000..a4e6391 --- /dev/null +++ b/app/Policies/CalendarFeedPolicy.php @@ -0,0 +1,36 @@ +team_id === $user->current_team_id; + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, CalendarFeed $feed): bool + { + return $feed->team_id === $user->current_team_id; + } + + public function delete(User $user, CalendarFeed $feed): bool + { + return $feed->team_id === $user->current_team_id; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index b1be166..1a33712 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,8 @@ withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ SetTeamUrlDefaults::class, + RestrictKioskSession::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 701e722..7770227 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,9 @@ "livewire/flux-pro": "^2.13", "livewire/livewire": "^4.1", "nunomaduro/essentials": "^1.2", + "sabre/vobject": "^4.5", + "saloonphp/laravel-plugin": "^4.3", + "saloonphp/saloon": "^4.0", "spatie/simple-excel": "^3.9" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 9fb4819..2455b85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "791fd78bee159cb1d764bc55ac57367b", + "content-hash": "8c12ca248c64e0a023e0a53357518065", "packages": [ { "name": "aws/aws-crt-php", @@ -5090,6 +5090,314 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabre/uri", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/a926c749dddfb289b8a9b5218d16ac06affdc631", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Sabre\\Uri\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "Functions for making sense out of URIs.", + "homepage": "http://sabre.io/uri/", + "keywords": [ + "rfc3986", + "uri", + "url" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/uri/issues", + "source": "https://github.com/fruux/sabre-uri" + }, + "time": "2026-04-26T04:19:03+00:00" + }, + { + "name": "sabre/vobject", + "version": "4.5.8", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1", + "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabre/xml": "^2.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.17.1", + "phpstan/phpstan": "^0.12 || ^1.12 || ^2.0", + "phpunit/php-invoker": "^2.0 || ^3.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "suggest": { + "hoa/bench": "If you would like to run the benchmark scripts" + }, + "bin": [ + "bin/vobject", + "bin/generate_vcards" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabre\\VObject\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Dominik Tobschall", + "email": "dominik@fruux.com", + "homepage": "http://tobschall.de/", + "role": "Developer" + }, + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net", + "homepage": "http://mnt.io/", + "role": "Developer" + } + ], + "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects", + "homepage": "http://sabre.io/vobject/", + "keywords": [ + "availability", + "freebusy", + "iCalendar", + "ical", + "ics", + "jCal", + "jCard", + "recurrence", + "rfc2425", + "rfc2426", + "rfc2739", + "rfc4770", + "rfc5545", + "rfc5546", + "rfc6321", + "rfc6350", + "rfc6351", + "rfc6474", + "rfc6638", + "rfc6715", + "rfc6868", + "vCalendar", + "vCard", + "vcf", + "xCal", + "xCard" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/vobject/issues", + "source": "https://github.com/fruux/sabre-vobject" + }, + "time": "2026-01-12T10:45:19+00:00" + }, + { + "name": "sabre/xml", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^8.2", + "sabre/uri": ">=2.0,<4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ], + "psr-4": { + "Sabre\\Xml\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/xml/issues", + "source": "https://github.com/fruux/sabre-xml" + }, + "time": "2026-04-27T10:56:01+00:00" + }, + { + "name": "saloonphp/laravel-plugin", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/saloonphp/laravel-plugin.git", + "reference": "c9754084dabe1002000d01cc2192fd08cfa6079d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/saloonphp/laravel-plugin/zipball/c9754084dabe1002000d01cc2192fd08cfa6079d", + "reference": "c9754084dabe1002000d01cc2192fd08cfa6079d", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0 || ^12.39.0 || ^13.0", + "illuminate/support": "^11.0 || ^12.39.0 || ^13.0", + "php": "^8.2", + "saloonphp/saloon": "^4.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.48", + "laravel/pulse": "^1.4", + "laravel/telescope": "^5.16", + "orchestra/testbench": "^9.15 || ^10.7 || ^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^1.10.57|^2.0.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Saloon": "Saloon\\Laravel\\Facades\\Saloon" + }, + "providers": [ + "Saloon\\Laravel\\SaloonServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Saloon\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sam Carré", + "email": "29132017+Sammyjo20@users.noreply.github.com", + "role": "Developer" + } + ], + "description": "The official Laravel plugin for Saloon", + "homepage": "https://github.com/saloonphp/laravel-plugin", + "keywords": [ + "api", + "api-integrations", + "saloon", + "saloonphp", + "sdk" + ], + "support": { + "source": "https://github.com/saloonphp/laravel-plugin/tree/v4.3.0" + }, + "time": "2026-04-23T23:22:12+00:00" + }, { "name": "saloonphp/saloon", "version": "v4.0.0", diff --git a/config/services.php b/config/services.php index 6a90eb8..4f963f1 100644 --- a/config/services.php +++ b/config/services.php @@ -14,6 +14,14 @@ | */ + 'mapbox' => [ + 'key' => env('MAPBOX_API_KEY'), + ], + + 'openweather' => [ + 'key' => env('OPENWEATHER_API_KEY'), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/database/factories/CalendarFeedFactory.php b/database/factories/CalendarFeedFactory.php new file mode 100644 index 0000000..1351e73 --- /dev/null +++ b/database/factories/CalendarFeedFactory.php @@ -0,0 +1,27 @@ + + */ +class CalendarFeedFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'name' => fake()->words(2, true), + 'url' => fake()->url() . '/calendar.ics', + 'color' => fake()->randomElement(CalendarColor::class), + ]; + } +} diff --git a/database/factories/KioskDeviceFactory.php b/database/factories/KioskDeviceFactory.php new file mode 100644 index 0000000..114d162 --- /dev/null +++ b/database/factories/KioskDeviceFactory.php @@ -0,0 +1,59 @@ + + */ +class KioskDeviceFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'uuid' => Str::uuid()->toString(), + 'pairing_code' => KioskDevice::generatePairingCode(), + 'user_agent' => fake()->userAgent(), + 'last_ip' => fake()->ipv4(), + 'expires_at' => now()->addMinutes(KioskDevice::PAIRING_TTL_MINUTES), + ]; + } + + public function pending(): self + { + return $this->state(fn (): array => [ + 'paired_at' => null, + 'user_id' => null, + 'team_id' => null, + 'expires_at' => now()->addMinutes(KioskDevice::PAIRING_TTL_MINUTES), + ]); + } + + public function paired(?User $user = null, ?Team $team = null): self + { + return $this->state(fn (): array => [ + 'user_id' => $user ?? User::factory(), + 'team_id' => $team ?? Team::factory(), + 'paired_at' => now(), + 'expires_at' => null, + 'pairing_code' => null, + 'name' => fake()->words(2, true), + ]); + } + + public function expired(): self + { + return $this->state(fn (): array => [ + 'paired_at' => null, + 'expires_at' => now()->subMinutes(5), + ]); + } +} diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php index 21ff380..db870a7 100644 --- a/database/factories/TeamFactory.php +++ b/database/factories/TeamFactory.php @@ -6,6 +6,7 @@ use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; /** @@ -22,6 +23,8 @@ public function definition(): array 'name' => $name, 'slug' => Str::slug($name), 'is_personal' => false, + 'timezone' => 'America/Chicago', + 'week_start' => Carbon::SUNDAY, ]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 00704a8..34ef8ee 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -63,4 +63,10 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + + public function memberOf(Team $team): static + { + return $this->hasAttached($team) + ->afterCreating(fn ($user) => $user->switchTeam($team)); + } } diff --git a/database/migrations/2026_05_14_204921_create_calendar_feeds_table.php b/database/migrations/2026_05_14_204921_create_calendar_feeds_table.php new file mode 100644 index 0000000..4ecdbac --- /dev/null +++ b/database/migrations/2026_05_14_204921_create_calendar_feeds_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('url'); + $table->string('color', 7)->default('#2563eb'); + $table->timestamps(); + + $table->index(['team_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('calendar_feeds'); + } +}; diff --git a/database/migrations/2026_05_14_211119_add_timezone_to_teams_table.php b/database/migrations/2026_05_14_211119_add_timezone_to_teams_table.php new file mode 100644 index 0000000..ece2c2b --- /dev/null +++ b/database/migrations/2026_05_14_211119_add_timezone_to_teams_table.php @@ -0,0 +1,25 @@ +string('timezone')->default('America/Chicago')->after('is_personal'); + $table->integer('week_start')->default(Carbon::SUNDAY)->after('timezone'); + $table->json('address')->nullable()->after('week_start'); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumns(['timezone', 'week_start', 'address']); + }); + } +}; diff --git a/database/migrations/2026_06_02_025423_create_kiosk_devices_table.php b/database/migrations/2026_06_02_025423_create_kiosk_devices_table.php new file mode 100644 index 0000000..d7271eb --- /dev/null +++ b/database/migrations/2026_06_02_025423_create_kiosk_devices_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('uuid')->unique(); + $table->string('pairing_code')->nullable()->unique(); + $table->string('name')->nullable(); + $table->string('user_agent')->nullable(); + $table->string('last_ip', 45)->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('team_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamp('paired_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + + $table->index(['team_id', 'paired_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('kiosk_devices'); + } +}; diff --git a/database/seeders/StrawhatsSeeder.php b/database/seeders/StrawhatsSeeder.php index c01bc44..e80c5ec 100644 --- a/database/seeders/StrawhatsSeeder.php +++ b/database/seeders/StrawhatsSeeder.php @@ -4,7 +4,9 @@ namespace Database\Seeders; +use App\Enums\CalendarColor; use App\Enums\ItemType; +use App\Models\CalendarFeed; use App\Models\Item; use App\Models\Recipe; use App\Models\Team; @@ -130,5 +132,22 @@ public function run(): void ['name' => 'Master Oda\'s Dinner Paparazzi! (Home)'], ) ->create(); + + CalendarFeed::factory() + ->for($strawhats) + ->count(10) + ->sequence( + ['name' => 'Brazilian Holidays (Luffy)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=BR&year=' . now()->format('Y'), 'color' => CalendarColor::Green], + ['name' => 'Japanese Holidays (Zoro)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=JP&year=' . now()->format('Y'), 'color' => CalendarColor::Red], + ['name' => 'Swedish Holidays (Nami)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=SE&year=' . now()->format('Y'), 'color' => CalendarColor::Gold], + ['name' => 'South African Holidays (Usopp)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=ZA&year=' . now()->format('Y'), 'color' => CalendarColor::Red], + ['name' => 'French Holidays (Sanji)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=FR&year=' . now()->format('Y'), 'color' => CalendarColor::Blue], + ['name' => 'Canadian Holidays (Chopper)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=CA&year=' . now()->format('Y'), 'color' => CalendarColor::Red], + ['name' => 'Russian Holidays (Robin)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=RU&year=' . now()->format('Y'), 'color' => CalendarColor::Blue], + ['name' => 'American Holidays (Franky)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=US&year=' . now()->format('Y'), 'color' => CalendarColor::Red], + ['name' => 'Austrian Holidays (Brook)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=AT&year=' . now()->format('Y'), 'color' => CalendarColor::Red], + ['name' => 'Indian Holidays (Jinbe)', 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=IN&year=' . now()->format('Y'), 'color' => CalendarColor::Orange], + ) + ->create(); } } diff --git a/resources/css/app.css b/resources/css/app.css index 3076b8d..e221be8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -10,6 +10,24 @@ @source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php'; @source '../../vendor/livewire/flux/stubs/**/*.blade.php'; +@utility animate-duration-* { + --animate-duration: --value(integer)ms; + --animate-duration: --value([time]); + animation-duration: var(--animate-duration); +} + +@utility animate-delay-* { + --animate-delay: --value(integer)ms; + --animate-delay: --value([time]); + animation-delay: var(--animate-delay); +} + +@utility animate-ease-* { + --animate-easing: --value(--ease-*); + --animate-easing: --value([*]); + animation-timing-function: var(--animate-easing); +} + .prose li p { margin: 0; diff --git a/resources/views/components/kiosk/calendar/day.blade.php b/resources/views/components/kiosk/calendar/day.blade.php new file mode 100644 index 0000000..3a7ea15 --- /dev/null +++ b/resources/views/components/kiosk/calendar/day.blade.php @@ -0,0 +1,79 @@ +
+
+
! $this->day['is_today'], + 'bg-blue-100 dark:bg-blue-900' => $this->day['is_today'], + ]) + > +
{{ $this->day['date']->format('l') }}
+
{{ $this->day['date']->format('F j') }}
+
+ + @if (count($this->dayAllDayEvents) > 0) +
+
+ {{ __('All day') }} +
+ +
+ @foreach ($this->dayAllDayEvents as $event) + + @endforeach +
+
+ @endif + +
+
+ @foreach (range(0, 23) as $hour) +
+ {{ Carbon\CarbonImmutable::createFromTime($hour)->format('g A') }} +
+ +
+ @endforeach + +
+ @foreach ($this->dayTimedEvents as $event) +
($event['response_status'] ?? null) === 'DECLINED', + ]) + style="top: {{ $event['timeline_top'] }}%; height: {{ $event['timeline_height'] }}%; left: 0; right: 0; min-height: 2.5rem; border-left: 4px {{ ($event['response_status'] ?? null) === 'NEEDS-ACTION' ? 'dashed' : 'solid' }} {{ $event['feed_color'] }}" + > +
+ {{ $this->eventTimeRange($event) }} +
+ +
{{ $event['title'] }}
+ + @if ($event['location']) +
{{ $event['location'] }}
+ @endif +
+ @endforeach +
+
+
+
+ + +
diff --git a/resources/views/components/kiosk/calendar/event.blade.php b/resources/views/components/kiosk/calendar/event.blade.php new file mode 100644 index 0000000..6d70e74 --- /dev/null +++ b/resources/views/components/kiosk/calendar/event.blade.php @@ -0,0 +1,18 @@ +
($event['response_status'] ?? null) === 'DECLINED', + ]) + style="border-left: 4px {{ ($event['response_status'] ?? null) === 'NEEDS-ACTION' ? 'dashed' : 'solid' }} {{ $event['feed_color'] }}" +> +
+ {{ $this->eventTimeRange($event) }} +
+ +
{{ $event['title'] }}
+ + @if ($event['location']) +
{{ $event['location'] }}
+ @endif +
diff --git a/resources/views/components/kiosk/calendar/month.blade.php b/resources/views/components/kiosk/calendar/month.blade.php new file mode 100644 index 0000000..53c9c42 --- /dev/null +++ b/resources/views/components/kiosk/calendar/month.blade.php @@ -0,0 +1,58 @@ +
+
+ {{ now()->format('F') }} +
+
+
+ @foreach (range(0, 6) as $offset) +
+ {{ $this->monthDays[$offset]['date']->format('D') }} +
+ @endforeach +
+ +
+ @foreach ($this->monthDays as $day) +
$day['is_current_month'] && ! $day['is_today'], + 'bg-zinc-50 text-zinc-500 dark:bg-zinc-900 dark:text-zinc-500' => ! $day['is_current_month'] && ! $day['is_today'], + 'bg-blue-100 dark:bg-blue-900' => $day['is_today'], + ]) + > +
$day['is_current_month'] || $day['is_today'], + 'text-zinc-400 dark:text-zinc-500' => ! $day['is_current_month'] && ! $day['is_today'], + ])> + {{ $day['date']->format('j') }} +
+ +
+ @foreach (array_slice($day['events'], 0, 2) as $event) +
($event['response_status'] ?? null) === 'DECLINED', + ]) + style="border-left: 3px {{ ($event['response_status'] ?? null) === 'NEEDS-ACTION' ? 'dashed' : 'solid' }} {{ $event['feed_color'] }}" + > + {{ $this->eventTimeRange($event) }} + {{ $event['title'] }} +
+ @endforeach + + @if (count($day['events']) > 2) +
+ {{ __('+:count events', ['count' => count($day['events']) - 2]) }} +
+ @endif +
+
+ @endforeach +
+
+
diff --git a/resources/views/components/kiosk/calendar/week.blade.php b/resources/views/components/kiosk/calendar/week.blade.php new file mode 100644 index 0000000..68e778e --- /dev/null +++ b/resources/views/components/kiosk/calendar/week.blade.php @@ -0,0 +1,22 @@ +
+ @foreach ($this->weekDays as $day) +
+
! $day['is_today'], + 'bg-blue-100 dark:bg-blue-900' => $day['is_today'], + ])> +
+
{{ $day['date']->format('D') }}
+
{{ $day['date']->format('M j') }}
+
+
+ +
+ @foreach ($day['events'] as $event) + + @endforeach +
+
+ @endforeach +
diff --git a/resources/views/components/kiosk/sidebar/index.blade.php b/resources/views/components/kiosk/sidebar/index.blade.php new file mode 100644 index 0000000..5f99995 --- /dev/null +++ b/resources/views/components/kiosk/sidebar/index.blade.php @@ -0,0 +1,17 @@ +@blaze(fold: true) + +@php + $classes = Flux::classes('[grid-area:sidebar]') + ->add('z-1 flex flex-col [:where(&)]:w-32') + ->add('bg-zinc-50 dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-700') + ->add('divide-y divide-zinc-200 dark:divide-zinc-700') + // ->add('data-flux-sidebar-collapsed-desktop:w-14 data-flux-sidebar-collapsed-desktop:px-2') + // ->add('data-flux-sidebar-collapsed-desktop:cursor-e-resize rtl:data-flux-sidebar-collapsed-desktop:cursor-w-resize') + ; +@endphp + +
class($classes) }} +> + {{ $slot }} +
diff --git a/resources/views/components/kiosk/sidebar/item.blade.php b/resources/views/components/kiosk/sidebar/item.blade.php new file mode 100644 index 0000000..204043d --- /dev/null +++ b/resources/views/components/kiosk/sidebar/item.blade.php @@ -0,0 +1,15 @@ +@props(['icon']) + +@php + $classes = Flux::classes() + ->add('flex flex-col items-center justify-center p-2 h-20') + ->add('data-current:text-(--color-accent-content) hover:data-current:text-(--color-accent-content)') + ->add('data-current:bg-white dark:data-current:bg-white/[7%]') + ->add('last:border-b last:border-zinc-200 last:dark:border-zinc-700') + ; +@endphp + +merge(['class' => $classes]) }}> + + {{ $slot }} + diff --git "a/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.blade.php" "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.blade.php" new file mode 100644 index 0000000..06bd5d4 --- /dev/null +++ "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.blade.php" @@ -0,0 +1,31 @@ +
+ @if ($temp !== null) +
+
+ {{ $location }} + {{ $description }} +
+
+ {{ $temp }}° +
+ {{ $high }}° + {{ $low }}° +
+
+
+ @else + +
+
+ + +
+
+ + + +
+
+
+ @endif +
diff --git "a/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.php" "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.php" new file mode 100644 index 0000000..ab1f6c7 --- /dev/null +++ "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.php" @@ -0,0 +1,55 @@ +currentTeam; + + if (! ($team->address['lat'] ?? null) || ! ($team->address['long'] ?? null)) { + return; + } + + try { + $weather = Cache::remember( + "weather:{$team->id}", + now()->addMinutes(30), + fn () => (new OpenWeatherConnector)->send( + new OneCall($team->address['lat'], $team->address['long'], 'minutely,hourly,alerts') + )->json(), + ); + } catch (RequestException) { + $weather = null; + } + + if (! $weather) { + return; + } + + $this->location = $team->address['city'] ?? null; + $this->temp = round($weather['current']['temp']); + $this->high = round($weather['daily'][0]['temp']['max']); + $this->low = round($weather['daily'][0]['temp']['min']); + $this->description = $weather['current']['weather'][0]['description'] ?? null; + $this->icon = $weather['current']['weather'][0]['icon'] ?? null; + } +}; diff --git "a/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.test.php" "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.test.php" new file mode 100644 index 0000000..1cfc0d1 --- /dev/null +++ "b/resources/views/components/kiosk/\342\232\241weather-tile/weather-tile.test.php" @@ -0,0 +1,81 @@ + MockResponse::make([ + 'current' => [ + 'temp' => 63.4, + 'weather' => [['description' => 'overcast clouds', 'icon' => '04d']], + ], + 'daily' => [ + ['temp' => ['max' => 73.2, 'min' => 55.1]], + ], + ]), + ]); + + $team = Team::factory()->create([ + 'address' => [ + 'address' => '123 Main St', + 'city' => 'Chicago', + 'state' => 'IL', + 'zip' => '60601', + 'lat' => '41.8781', + 'long' => '-87.6298', + ], + ]); + + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('kiosk.weather-tile') + ->assertSee('Chicago') + ->assertSee('63°') + ->assertSee('73°') + ->assertSee('55°'); + + Saloon::assertSent(OneCall::class); +}); + +test('shows skeleton when api returns 429', function () { + Saloon::fake([ + OneCall::class => MockResponse::make( + ['cod' => 429, 'message' => 'Too Many Requests'], + 429, + ), + ]); + + $team = Team::factory()->create([ + 'address' => [ + 'address' => '123 Main St', + 'city' => 'Chicago', + 'state' => 'IL', + 'zip' => '60601', + 'lat' => '41.8781', + 'long' => '-87.6298', + ], + ]); + + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('kiosk.weather-tile') + ->assertDontSee('°') + ->assertSee('shimmer'); + + Saloon::assertSent(OneCall::class); +}); + +test('renders nothing without address coordinates', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('kiosk.weather-tile') + ->assertDontSee('°'); +}); diff --git a/resources/views/components/screensize.blade.php b/resources/views/components/screensize.blade.php new file mode 100644 index 0000000..3f1b041 --- /dev/null +++ b/resources/views/components/screensize.blade.php @@ -0,0 +1,10 @@ +@env('local') +
+ base + + + + + +
+@endenv diff --git a/resources/views/components/ui/clock.blade.php b/resources/views/components/ui/clock.blade.php new file mode 100644 index 0000000..595d978 --- /dev/null +++ b/resources/views/components/ui/clock.blade.php @@ -0,0 +1,40 @@ +@props(['timezone']) + +
+ + {{ Carbon\CarbonImmutable::now($this->timezoneName())->format('D, M j g:i A') }} + +
diff --git a/resources/views/components/ui/dotx3.blade.php b/resources/views/components/ui/dotx3.blade.php new file mode 100644 index 0000000..30932ab --- /dev/null +++ b/resources/views/components/ui/dotx3.blade.php @@ -0,0 +1,2 @@ +... +{{-- Requires adding utility classes to app.css. @see https://www.hyperui.dev/blog/animation-duration-delay-with-tailwindcss/ --}} diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8f08c05..c8dd8d5 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,18 +1,6 @@ -
-
-
- -
-
- -
-
- -
-
-
- -
-
+ What would you like to see here? + + {{ __('Make a Suggestion') }} +
diff --git a/resources/views/flux/icon/cooking-pot.blade.php b/resources/views/flux/icon/cooking-pot.blade.php new file mode 100644 index 0000000..7e732ea --- /dev/null +++ b/resources/views/flux/icon/cooking-pot.blade.php @@ -0,0 +1,46 @@ +@blaze(fold: true) + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 05ce63a..40e676c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -4,29 +4,38 @@ @include('layouts.partials.head') - + + + + + + - - - {{ __('Dashboard') }} - + + {{ __('Dashboard') }} + - - {{ __('Inventory') }} - + + {{ __('Inventory') }} + - - {{ __('Recipes') }} - + + {{ __('Recipes') }} + - + + {{ __('Kiosk') }} + - {{ __('Make a Suggestion') }} @@ -103,6 +112,8 @@ class="w-full cursor-pointer" {{ $slot }} + + @persist('toast') diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index 394c5e6..13319cf 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -17,6 +17,13 @@ + + + + @persist('toast') + + @endpersist + @fluxScripts diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php index 90a91f3..cd487d9 100644 --- a/resources/views/layouts/guest.blade.php +++ b/resources/views/layouts/guest.blade.php @@ -15,6 +15,8 @@ {{ $slot }} + + @persist('toast') @endpersist diff --git a/resources/views/layouts/kiosk-configure.blade.php b/resources/views/layouts/kiosk-configure.blade.php new file mode 100644 index 0000000..c960abc --- /dev/null +++ b/resources/views/layouts/kiosk-configure.blade.php @@ -0,0 +1,25 @@ + +
+ {{ __('Kiosk Configuration') }} + {{ __('Preview your kiosk and manage its data sources') }} + +
+ +
+
+ + {{ __('Preview') }} + {{ __('Calendar') }} + {{ __('Settings') }} + +
+ + + +
+
+ {{ $slot }} +
+
+
+
diff --git a/resources/views/layouts/kiosk.blade.php b/resources/views/layouts/kiosk.blade.php new file mode 100644 index 0000000..417b581 --- /dev/null +++ b/resources/views/layouts/kiosk.blade.php @@ -0,0 +1,39 @@ + + + + @include('layouts.partials.head') + + + + + {{ __('Calendar') }} + {{ __('Routines') }} + {{ __('Chores') }} + {{ __('Lists') }} + {{ __('Meals') }} + + + + + + {{ $slot }} + + + + + @persist('toast') + + @endpersist + + @fluxScripts + + diff --git a/resources/views/layouts/partials/head.blade.php b/resources/views/layouts/partials/head.blade.php index 0665c3f..d6b2a30 100644 --- a/resources/views/layouts/partials/head.blade.php +++ b/resources/views/layouts/partials/head.blade.php @@ -28,3 +28,4 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) @fluxAppearance +@stack('head') diff --git "a/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.blade.php" "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.blade.php" new file mode 100644 index 0000000..3ad1a5c --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.blade.php" @@ -0,0 +1,41 @@ +
+
+ {{ __('Calendar feeds') }} + + {{ __('Add Calendar Feed') }} + +
+ +
+ @forelse ($this->feeds as $feed) + +
+
+ + + {{ $feed->name }} + + + {{ $feed->url }} +
+ +
+ + + + + + + +
+
+
+ @empty +
+ {{ __('No calendar feeds yet.') }} +
+ @endforelse +
+ + @include('pages.kiosk.modals.feed-form') +
diff --git "a/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.php" "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.php" new file mode 100644 index 0000000..2dc8453 --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.php" @@ -0,0 +1,55 @@ +currentTeam->calendarFeeds()->orderBy('name')->get(); + } + + public function delete(int $id): void + { + $feed = $this->feeds->firstWhere('id', $id); + + $this->authorize('delete', $feed); + + $feed->delete(); + + unset($this->feeds); + Flux::toast(variant: 'success', text: __('Calendar feed removed.')); + } + + public function edit(int $id): void + { + $feed = $this->feeds->firstWhere('id', $id); + + $this->authorize('update', $feed); + + $this->form->load($feed); + $this->modal('feed-form')->show(); + } + + public function save(): void + { + if ($this->form->editingFeed) { + $this->authorize('update', $this->form->editingFeed); + } else { + $this->authorize('create', CalendarFeed::class); + } + + $this->form->save(); + $this->modal('feed-form')->close(); + unset($this->feeds); + } +}; diff --git "a/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.test.php" "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.test.php" new file mode 100644 index 0000000..9a7a343 --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241calendar/calendar.test.php" @@ -0,0 +1,118 @@ +has(CalendarFeed::factory()->state([ + 'name' => 'Family Calendar', + 'url' => 'https://example.com/family.ics', + 'color' => CalendarColor::Green, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + + actingAs($user) + ->get(route('kiosk.configure.calendar')) + ->assertOk() + ->assertSee('Family Calendar'); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.calendar') + ->assertOk() + ->assertSee('Family Calendar'); +})->group('smoke'); + +test('can add a calendar feed', function () { + $team = Team::factory()->create(); + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.calendar') + ->set('form.name', 'Brazilian Holidays') + ->set('form.url', 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=BR&year=2026') + ->set('form.color', CalendarColor::Green->value) + ->call('save') + ->assertHasNoErrors() + ->assertSet('form.name', '') + ->assertSet('form.url', '') + ->assertSet('form.color', CalendarColor::Blue->value); + + $this->assertDatabaseHas('calendar_feeds', [ + 'team_id' => $team->id, + 'name' => 'Brazilian Holidays', + 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=BR&year=2026', + 'color' => CalendarColor::Green->value, + ]); +}); + +test('can edit a team calendar feed', function () { + $team = Team::factory() + ->has(CalendarFeed::factory()->state([ + 'name' => 'US Holidays', + 'url' => 'https://example.com/us.ics', + 'color' => CalendarColor::Red, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + $feed = $team->calendarFeeds()->firstOrFail(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.calendar') + ->call('edit', $feed->id) + ->assertSet('form.name', 'US Holidays') + ->set('form.name', 'American Holidays') + ->set('form.url', 'https://example.com/american.ics') + ->set('form.color', CalendarColor::Indigo->value) + ->call('save') + ->assertHasNoErrors() + ->assertSet('form.editingFeed', null); + + $this->assertDatabaseHas('calendar_feeds', [ + 'id' => $feed->id, + 'team_id' => $team->id, + 'name' => 'American Holidays', + 'url' => 'https://example.com/american.ics', + 'color' => CalendarColor::Indigo->value, + ]); +}); + +test('can remove a team calendar feed', function () { + $team = Team::factory() + ->has(CalendarFeed::factory()->state([ + 'name' => 'US Holidays', + 'url' => 'https://example.com/us.ics', + 'color' => CalendarColor::Red, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + $feed = $team->calendarFeeds()->firstOrFail(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.calendar') + ->call('delete', $feed->id) + ->assertHasNoErrors() + ->assertDontSee('US Holidays'); + + $this->assertDatabaseMissing('calendar_feeds', [ + 'id' => $feed->id, + ]); +}); + +test('validates calendar feed input', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.calendar') + ->set('form.name', '') + ->set('form.url', 'not-a-url') + ->set('form.color', '#ffffff') + ->call('save') + ->assertHasErrors(['form.name', 'form.url', 'form.color']); +}); diff --git "a/resources/views/pages/kiosk/configure/\342\232\241preview.blade.php" "b/resources/views/pages/kiosk/configure/\342\232\241preview.blade.php" new file mode 100644 index 0000000..90a102f --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241preview.blade.php" @@ -0,0 +1,44 @@ +width; + $height = $this->height; + + $this->width = $height; + $this->height = $width; + } +}; +?> + +
+
+ + {{ __('Width') }} + + + px + + + + {{ __('Height') }} + + + px + + + + Preview in New Tab +
+
+ +
+
diff --git "a/resources/views/pages/kiosk/configure/\342\232\241settings/settings.blade.php" "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.blade.php" new file mode 100644 index 0000000..974bd17 --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.blade.php" @@ -0,0 +1,88 @@ +
+
+ + @foreach (DateTimeZone::listIdentifiers() as $timezoneOption) + {{ str_replace('_', ' ', $timezoneOption) }} + @endforeach + + + + {{ __('Sunday') }} + {{ __('Monday') }} + {{ __('Tuesday') }} + {{ __('Wednesday') }} + {{ __('Thursday') }} + {{ __('Friday') }} + {{ __('Saturday') }} + + + + Address for Weather + + + + + +
+ + + +
+
+ + Save +
+ +
+ {{ __('Paired displays') }} + + {{ __('Devices that are signed in to the kiosk view for this team.') }} + + + @if ($this->pairedDevices->isEmpty()) + + {{ __('No paired displays yet.') }} + + @else +
    + @foreach ($this->pairedDevices as $device) + +
    + {{ $device->name ?: __('Unnamed display') }} + + {{ __('Paired') }} {{ $device->paired_at?->diffForHumans() }} + @if ($device->last_seen_at) + · {{ __('Last seen') }} {{ $device->last_seen_at->diffForHumans() }} + @endif + +
    + + {{ __('Forget') }} + +
    + @endforeach +
+ @endif +
+
+ +@assets + +@endassets + +@script + +@endscript diff --git "a/resources/views/pages/kiosk/configure/\342\232\241settings/settings.php" "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.php" new file mode 100644 index 0000000..703ab21 --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.php" @@ -0,0 +1,45 @@ +form->load(Auth::user()->currentTeam); + } + + public function save() + { + $this->form->save(); + } + + public function forget(int $deviceId): void + { + KioskDevice::query() + ->where('team_id', Auth::user()->current_team_id) + ->whereKey($deviceId) + ->delete(); + + unset($this->pairedDevices); + } + + /** @return Collection */ + #[Computed] + public function pairedDevices(): Collection + { + return KioskDevice::query() + ->where('team_id', Auth::user()->current_team_id) + ->paired() + ->orderByDesc('last_seen_at') + ->get(); + } +}; diff --git "a/resources/views/pages/kiosk/configure/\342\232\241settings/settings.test.php" "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.test.php" new file mode 100644 index 0000000..c86d51e --- /dev/null +++ "b/resources/views/pages/kiosk/configure/\342\232\241settings/settings.test.php" @@ -0,0 +1,102 @@ +create([ + 'name' => 'Straw Hats', + 'timezone' => 'Asia/Tokyo', + 'week_start' => Carbon::MONDAY, + ]); + + $user = User::factory()->memberOf($team)->create(); + + actingAs($user) + ->get(route('kiosk.configure.settings')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.settings') + ->assertOk() + ->assertSet('form.timezone', 'Asia/Tokyo') + ->assertSet('form.week_start', Carbon::MONDAY); +})->group('smoke'); + +test('can change kiosk settings', function () { + $team = Team::factory()->create([ + 'name' => 'Straw Hats', + 'timezone' => 'Asia/Tokyo', + 'week_start' => Carbon::MONDAY, + ]); + + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.settings') + ->set('form.timezone', 'America/Sao_Paulo') + ->set('form.week_start', Carbon::SUNDAY) + ->set('form.address', [ + 'address' => '123 Grand Line', + 'city' => 'East Blue', + 'state' => 'GL', + 'zip' => '00001', + 'lat' => '0.0', + 'long' => '0.0', + ]) + ->call('save') + ->assertHasNoErrors(); + + expect($team->fresh()) + ->timezone->toBe('America/Sao_Paulo') + ->week_start->toBe(Carbon::SUNDAY); +}); + +test('options must be valid', function () { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.settings') + ->set('form.timezone', 'Not/AZone') + ->set('form.week_start', 15) + ->call('save') + ->assertHasErrors(['form.timezone', 'form.week_start']); +}); + +describe('device management', function () { + test('can forget a paired display for the current team', function () { + $user = User::factory()->create(); + + $kitchen = KioskDevice::factory()->paired($user, $user->currentTeam)->create(['name' => 'Kitchen']); + $bedroom = KioskDevice::factory()->paired($user, $user->currentTeam)->create(['name' => 'Bedroom']); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.settings') + ->assertSee('Kitchen') + ->assertSee('Bedroom') + ->call('forget', $kitchen->id) + ->assertDontSee('Kitchen') + ->assertSee('Bedroom'); + + assertModelMissing($kitchen); + assertModelExists($bedroom); + }); + + test('cannot forget device from another team', function () { + $user = User::factory()->create(); + $other = KioskDevice::factory()->paired()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.configure.settings') + ->call('forget', $other->id); + + assertModelExists($other); + }); +}); diff --git a/resources/views/pages/kiosk/modals/feed-form.blade.php b/resources/views/pages/kiosk/modals/feed-form.blade.php new file mode 100644 index 0000000..cfea931 --- /dev/null +++ b/resources/views/pages/kiosk/modals/feed-form.blade.php @@ -0,0 +1,29 @@ +@teleport('body') + +
+ {{ $form->editingFeed ? __('Edit Feed') : __('Add Feed') }} + + + + + + + @foreach (\App\Enums\CalendarColor::cases() as $color) + +
+
{{ ucfirst($color->name) }} +
+
+ @endforeach +
+ +
+ + + {{ __('Cancel') }} + + {{ $form->editingFeed ? __('Update') : __('Create') }} +
+ +
+@endteleport diff --git "a/resources/views/pages/kiosk/\342\232\241calendar/calendar.blade.php" "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.blade.php" new file mode 100644 index 0000000..49d2196 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.blade.php" @@ -0,0 +1,35 @@ +
+
+ + +
+ + + + + @foreach ($this->feeds as $feed) + + + {{ $feed->name }} + + @endforeach + + + + + + + + + + + + + {{ __('Today') }} + + +
+
+ + +
diff --git "a/resources/views/pages/kiosk/\342\232\241calendar/calendar.php" "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.php" new file mode 100644 index 0000000..f05312b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.php" @@ -0,0 +1,246 @@ +focusedDate = CarbonImmutable::now($this->timezoneName())->toDateString(); + + $this->selectedFeeds = $this->feeds->pluck('id')->toArray(); + } + + /** @return EloquentCollection */ + #[Computed] + public function feeds(): EloquentCollection + { + return Auth::user()->currentTeam + ->calendarFeeds() + ->get(); + } + + /** @return array> */ + #[Computed] + public function dayEvents(): array + { + return $this->eventsForRange($this->focusedDate(), 1); + } + + /** @return array{date: CarbonImmutable, events: array>, is_today: bool} */ + #[Computed] + public function day(): array + { + $date = $this->focusedDate(); + + return [ + 'date' => $date, + 'events' => $this->dayEvents, + 'is_today' => $date->isSameDay(CarbonImmutable::now($this->timezoneName())), + ]; + } + + /** @return array> */ + #[Computed] + public function dayAllDayEvents(): array + { + return collect($this->dayEvents) + ->filter(fn (array $event): bool => $event['all_day']) + ->values() + ->all(); + } + + /** @return array> */ + #[Computed] + public function dayTimedEvents(): array + { + $dayStartsAt = $this->focusedDate()->startOfDay(); + $dayEndsAt = $this->focusedDate()->endOfDay(); + + return collect($this->dayEvents) + ->reject(fn (array $event): bool => $event['all_day']) + ->map(function (array $event) use ($dayEndsAt, $dayStartsAt): array { + $startsAt = $event['starts_at']->lessThan($dayStartsAt) ? $dayStartsAt : $event['starts_at']; + $endsAt = $event['ends_at'] instanceof CarbonImmutable ? $event['ends_at'] : $event['starts_at']->addMinutes(30); + $endsAt = $endsAt->greaterThan($dayEndsAt) ? $dayEndsAt : $endsAt; + $duration = max(15, (int) round($startsAt->diffInMinutes($endsAt))); + $startsAfterMidnight = (int) round($dayStartsAt->diffInMinutes($startsAt)); + + return [ + ...$event, + 'timeline_top' => ($startsAfterMidnight / 1440) * 100, + 'timeline_height' => ($duration / 1440) * 100, + ]; + }) + ->values() + ->all(); + } + + /** @return array> */ + #[Computed] + public function weekEvents(): array + { + return $this->eventsForRange($this->weekStartsAt(), 7); + } + + /** @return array>, is_today: bool}> */ + #[Computed] + public function weekDays(): array + { + $events = collect($this->weekEvents); + + return collect(range(0, 6)) + ->map(function (int $offset) use ($events): array { + $date = $this->weekStartsAt()->addDays($offset); + + return [ + 'date' => $date, + 'events' => $events + ->filter(fn (array $event) => $event['starts_at']->isSameDay($date)) + ->values() + ->all(), + 'is_today' => $date->isSameDay(CarbonImmutable::now($this->timezoneName())), + ]; + }) + ->all(); + } + + /** @return array> */ + #[Computed] + public function monthEvents(): array + { + return $this->eventsForRange($this->monthGridStartsAt(), 42); + } + + /** @return array>, is_today: bool, is_current_month: bool}> */ + #[Computed] + public function monthDays(): array + { + $events = collect($this->monthEvents); + $month = $this->monthStartsAt(); + + return collect(range(0, 41)) + ->map(function (int $offset) use ($events, $month): array { + $date = $this->monthGridStartsAt()->addDays($offset); + + return [ + 'date' => $date, + 'events' => $events + ->filter(fn (array $event) => $event['starts_at']->isSameDay($date)) + ->values() + ->all(), + 'is_today' => $date->isSameDay(CarbonImmutable::now($this->timezoneName())), + 'is_current_month' => $date->isSameMonth($month), + ]; + }) + ->all(); + } + + public function previous(): void + { + $this->focusedDate = match ($this->format) { + 'day' => $this->focusedDate()->subDay()->toDateString(), + 'month' => $this->focusedDate()->subMonthNoOverflow()->toDateString(), + default => $this->weekStartsAt()->subWeek()->toDateString(), + }; + + $this->clearCalendarState(); + } + + public function next(): void + { + $this->focusedDate = match ($this->format) { + 'day' => $this->focusedDate()->addDay()->toDateString(), + 'month' => $this->focusedDate()->addMonthNoOverflow()->toDateString(), + default => $this->weekStartsAt()->addWeek()->toDateString(), + }; + + $this->clearCalendarState(); + } + + public function current(): void + { + $this->focusedDate = CarbonImmutable::now($this->timezoneName())->toDateString(); + + $this->clearCalendarState(); + } + + /** @param array{starts_at: CarbonImmutable, ends_at: CarbonImmutable|null, all_day: bool} $event */ + public function eventTimeRange(array $event): string + { + if ($event['all_day']) { + return __('All day'); + } + + if (! $event['ends_at'] instanceof CarbonImmutable) { + return $event['starts_at']->format('g:i A'); + } + + $startsAtFormat = $event['starts_at']->format('A') === $event['ends_at']->format('A') + ? 'g:i' + : 'g:i A'; + + return $event['starts_at']->format($startsAtFormat) . ' - ' . $event['ends_at']->format('g:i A'); + } + + private function weekStartsAt(): CarbonImmutable + { + return $this->focusedDate() + ->startOfWeek(Auth::user()->currentTeam->week_start); + } + + private function monthStartsAt(): CarbonImmutable + { + return $this->focusedDate()->startOfMonth(); + } + + private function monthGridStartsAt(): CarbonImmutable + { + return $this->monthStartsAt() + ->startOfWeek(Auth::user()->currentTeam->week_start); + } + + private function focusedDate(): CarbonImmutable + { + return CarbonImmutable::parse($this->focusedDate, $this->timezoneName()); + } + + /** @return array> */ + private function eventsForRange(CarbonImmutable $startsAt, int $days): array + { + return $this->feeds + ->whereIn('id', $this->selectedFeeds) + ->flatMap(function (CalendarFeed $feed) use ($days, $startsAt) { + return resolve(FetchCalendarEvents::class)->handle($feed, $days, $startsAt); + }) + ->sortBy('starts_at') + ->values() + ->all(); + } + + private function clearCalendarState(): void + { + unset($this->dayEvents, $this->day, $this->dayAllDayEvents, $this->dayTimedEvents, $this->weekEvents, $this->weekDays, $this->monthEvents, $this->monthDays); + } + + private function timezoneName(): string + { + return Auth::user()->currentTeam->timezone ?: 'America/Chicago'; + } +}; diff --git "a/resources/views/pages/kiosk/\342\232\241calendar/calendar.test.php" "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.test.php" new file mode 100644 index 0000000..8facf2e --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241calendar/calendar.test.php" @@ -0,0 +1,281 @@ +create(); + + actingAs($user) + ->get(route('kiosk.calendar')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->assertOk(); +})->group('smoke'); + +test('can view events from feed', function () { + Http::allowStrayRequests(['https://calendar.google.com/calendar/ical/*']); + + $this->travelTo(Date::parse('2026-05-08')); + + $team = Team::factory() + ->has(CalendarFeed::factory()->state([ + 'url' => 'https://calendar.google.com/calendar/ical/en.usa%23holiday%40group.v.calendar.google.com/public/basic.ics', + 'name' => 'US Holidays', + 'color' => CalendarColor::Green, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->assertSee('Cinco de Mayo') + ->set('format', 'day') + ->set('focusedDate', '2026-05-05') + ->assertSee('Cinco de Mayo') + ->set('format', 'month') + ->assertSee('Cinco de Mayo'); +}); + +test('can view and navigate a day calendar', function () { + $this->travelTo(Date::parse('2026-05-08 12:00:00', 'America/Chicago')); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->set('format', 'day') + ->assertSee('Friday') + ->assertSee('May 8') + ->call('previous') + ->assertSee('Thursday') + ->assertSee('May 7') + ->call('current') + ->assertSee('Friday') + ->assertSee('May 8') + ->call('next') + ->assertSee('Saturday') + ->assertSee('May 9'); +}); + +test('day calendar shows hours and sizes timed events by duration', function () { + Http::fake([ + 'https://example.com/day-calendar.ics' => Http::response(<<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Sunny//Tests//EN +BEGIN:VEVENT +UID:family-day +DTSTAMP:20260501T120000Z +DTSTART;VALUE=DATE:20260508 +DTEND;VALUE=DATE:20260509 +SUMMARY:Family Day +END:VEVENT +BEGIN:VEVENT +UID:morning-standup +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260508T090000 +DTEND;TZID=America/Chicago:20260508T103000 +SUMMARY:Morning Standup +LOCATION:Kitchen +END:VEVENT +BEGIN:VEVENT +UID:family-lunch +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260508T100000 +DTEND;TZID=America/Chicago:20260508T130000 +SUMMARY:Family Lunch +END:VEVENT +BEGIN:VEVENT +UID:evening-game +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260508T170000 +DTEND;TZID=America/Chicago:20260508T190000 +SUMMARY:Evening Game +END:VEVENT +END:VCALENDAR +ICS), + ]); + + $this->travelTo(Date::parse('2026-05-08 12:00:00', 'America/Chicago')); + + $team = Team::factory() + ->has(CalendarFeed::factory()->state([ + 'url' => 'https://example.com/day-calendar.ics', + 'name' => 'Family Calendar', + 'color' => CalendarColor::Blue, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->set('format', 'day') + ->assertSee('12 AM') + ->assertSee('9 AM') + ->assertSee('All day') + ->assertSee('Family Day') + ->assertSee('Morning Standup') + ->assertSee('Family Lunch') + ->assertSee('Evening Game') + ->assertSee('Kitchen') + ->assertSee('9:00 - 10:30 AM') + ->assertSee('10:00 AM - 1:00 PM') + ->assertSee('5:00 - 7:00 PM') + ->assertSee('top: 37.5%', false) + ->assertSee('height: 6.25%', false); +}); + +test('can go to the next and previous weeks', function () { + $this->travelTo(Date::parse('2026-05-08')); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->assertSeeInOrder([ + 'calendar-day-2026-05-03', 'calendar-day-2026-05-04', 'calendar-day-2026-05-05', 'calendar-day-2026-05-06', 'calendar-day-2026-05-07', 'calendar-day-2026-05-08', 'calendar-day-2026-05-09', + ]) + ->call('previous') + ->assertSeeInOrder([ + 'calendar-day-2026-04-26', 'calendar-day-2026-04-27', 'calendar-day-2026-04-28', 'calendar-day-2026-04-29', 'calendar-day-2026-04-30', 'calendar-day-2026-05-01', 'calendar-day-2026-05-02', + ]) + ->call('current') + ->assertSeeInOrder([ + 'calendar-day-2026-05-03', 'calendar-day-2026-05-04', 'calendar-day-2026-05-05', 'calendar-day-2026-05-06', 'calendar-day-2026-05-07', 'calendar-day-2026-05-08', 'calendar-day-2026-05-09', + ]) + ->call('next') + ->assertSeeInOrder([ + 'calendar-day-2026-05-10', 'calendar-day-2026-05-11', 'calendar-day-2026-05-12', 'calendar-day-2026-05-13', 'calendar-day-2026-05-14', 'calendar-day-2026-05-15', 'calendar-day-2026-05-16', + ]); +}); + +test('can view and navigate a month calendar', function () { + $this->travelTo(Date::parse('2026-05-15')); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->set('format', 'month') + ->assertSeeInOrder([ + 'calendar-day-2026-04-26', 'calendar-day-2026-04-27', 'calendar-day-2026-04-28', 'calendar-day-2026-04-29', 'calendar-day-2026-04-30', 'calendar-day-2026-05-01', 'calendar-day-2026-05-02', + 'calendar-day-2026-05-03', 'calendar-day-2026-05-04', 'calendar-day-2026-05-05', 'calendar-day-2026-05-06', 'calendar-day-2026-05-07', 'calendar-day-2026-05-08', 'calendar-day-2026-05-09', + 'calendar-day-2026-05-31', 'calendar-day-2026-06-01', 'calendar-day-2026-06-02', 'calendar-day-2026-06-03', 'calendar-day-2026-06-04', 'calendar-day-2026-06-05', 'calendar-day-2026-06-06', + ]) + ->call('previous') + ->assertSeeInOrder([ + 'calendar-day-2026-03-29', 'calendar-day-2026-03-30', 'calendar-day-2026-03-31', 'calendar-day-2026-04-01', 'calendar-day-2026-04-02', 'calendar-day-2026-04-03', 'calendar-day-2026-04-04', + ]) + ->call('current') + ->assertSeeInOrder([ + 'calendar-day-2026-04-26', 'calendar-day-2026-04-27', 'calendar-day-2026-04-28', 'calendar-day-2026-04-29', 'calendar-day-2026-04-30', 'calendar-day-2026-05-01', 'calendar-day-2026-05-02', + ]) + ->call('next') + ->assertSeeInOrder([ + 'calendar-day-2026-05-31', 'calendar-day-2026-06-01', 'calendar-day-2026-06-02', 'calendar-day-2026-06-03', 'calendar-day-2026-06-04', 'calendar-day-2026-06-05', 'calendar-day-2026-06-06', + ]); +}); + +test('month calendar shows two events before overflow count', function () { + Http::fake([ + 'https://example.com/month-calendar.ics' => Http::response(<<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Sunny//Tests//EN +BEGIN:VEVENT +UID:first-event +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260515T090000 +DTEND;TZID=America/Chicago:20260515T100000 +SUMMARY:First Event +END:VEVENT +BEGIN:VEVENT +UID:second-event +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260515T110000 +DTEND;TZID=America/Chicago:20260515T120000 +SUMMARY:Second Event +END:VEVENT +BEGIN:VEVENT +UID:third-event +DTSTAMP:20260501T120000Z +DTSTART;TZID=America/Chicago:20260515T130000 +DTEND;TZID=America/Chicago:20260515T140000 +SUMMARY:Third Event +END:VEVENT +END:VCALENDAR +ICS), + ]); + + $this->travelTo(Date::parse('2026-05-15 12:00:00', 'America/Chicago')); + + $team = Team::factory() + ->has(CalendarFeed::factory()->state([ + 'url' => 'https://example.com/month-calendar.ics', + 'name' => 'Family Calendar', + 'color' => CalendarColor::Blue, + ])) + ->create(); + $user = User::factory()->memberOf($team)->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->set('format', 'month') + ->assertSee('First Event') + ->assertSee('Second Event') + ->assertSee('+1 events') + ->assertDontSee('Third Event'); +}); + +test('can hide feed from calendar', function () { + Http::allowStrayRequests([ + 'https://calendar.google.com/calendar/ical/*', + 'https://worldpublicholiday.com/calendar-feeds/*', + ]); + + $this->travelTo(Date::parse('2026-03-20')); + + $team = Team::factory() + ->has( + CalendarFeed::factory() + ->count(2) + ->sequence([ + 'url' => 'https://calendar.google.com/calendar/ical/en.usa%23holiday%40group.v.calendar.google.com/public/basic.ics', + 'name' => 'US Holidays', + 'color' => CalendarColor::Green, + ], [ + 'url' => 'https://worldpublicholiday.com/calendar-feeds/feed.ics?country=BR&year=2026', + 'name' => 'Brazilian Holidays', + 'color' => CalendarColor::Blue, + ]) + ) + ->create(); + $user = User::factory()->memberOf($team)->create(); + [$brazilianHolidays, $usHolidays] = $team->calendarFeeds; + + Livewire::actingAs($user) + ->test('pages::kiosk.calendar') + ->assertSet('selectedFeeds', [$brazilianHolidays->id, $usHolidays->id]) + ->assertSeeInOrder([ + 'calendar-day-2026-03-17', "St. Patrick's Day", + 'calendar-day-2026-03-18', 'Autonomia do Estado', + 'calendar-day-2026-03-19', 'Dia de São José', + ]) + ->set('selectedFeeds', [$usHolidays->id]) + ->assertSeeInOrder([ + 'calendar-day-2026-03-17', "St. Patrick's Day", + 'calendar-day-2026-03-18', // 'Autonomia do Estado', + 'calendar-day-2026-03-19', // 'Dia de São José' + ]) + ->assertDontSee(['Autonomia do Estado', 'Dia de São José']); +}); diff --git "a/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.blade.php" "b/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.blade.php" new file mode 100644 index 0000000..e0c7043 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.blade.php" @@ -0,0 +1,9 @@ +
+
+ + {{ __('Chore Chart') }} + + {{ __('Household chores and assignments will live here.') }} + +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.php" "b/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.php" new file mode 100644 index 0000000..80b1f7b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241chore-chart/chore-chart.php" @@ -0,0 +1,9 @@ +create(); + + actingAs($user) + ->get(route('kiosk.chore-chart')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.chore-chart') + ->assertOk(); +})->group('smoke'); diff --git "a/resources/views/pages/kiosk/\342\232\241index/index.blade.php" "b/resources/views/pages/kiosk/\342\232\241index/index.blade.php" new file mode 100644 index 0000000..d2d757d --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241index/index.blade.php" @@ -0,0 +1,23 @@ +
+ + {{ __('Pair this display') }} + + {{ __('Scan the QR code with your phone, or visit the URL and enter the code below.') }} + + + + {!! $this->qrSvg !!} + + +
+ + {{ __('Pairing code') }} + + {{ $pairingCode }} +
+ + + {{ __('Waiting for confirmation') }} + +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241index/index.php" "b/resources/views/pages/kiosk/\342\232\241index/index.php" new file mode 100644 index 0000000..b96e67b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241index/index.php" @@ -0,0 +1,181 @@ +resolveDevice(); + + if ($device->isPaired()) { + $this->loginAndRedirect($device); + + return; + } + + $this->syncFromDevice($device); + } + + public function check(): mixed + { + $device = $this->deviceFromCookie(); + + if (! $device) { + return $this->redirect(request()->fullUrl(), navigate: false); + } + + $device->forceFill(['last_seen_at' => now()])->save(); + + if ($device->isPaired()) { + return $this->loginAndRedirect($device); + } + + if ($device->expires_at && $device->expires_at->isPast()) { + $this->rotateCode($device); + } + + $this->syncFromDevice($device); + + return null; + } + + #[Computed] + public function qrSvg(): string + { + $renderer = new ImageRenderer( + new RendererStyle(280), + new SvgImageBackEnd, + ); + + return (new Writer($renderer))->writeString(route('kiosk.pair', ['code' => $this->pairingCode])); + } + + protected function resolveDevice(): KioskDevice + { + $uuid = request()->cookie(self::COOKIE_NAME); + + if ($uuid) { + $device = KioskDevice::query()->where('uuid', $uuid)->first(); + + if ($device && ($device->isPaired() || ! $device->expires_at?->isPast())) { + return $device; + } + + if ($device) { + $this->rotateCode($device); + + return $device->fresh(); + } + } + + return $this->createDevice(); + } + + protected function deviceFromCookie(): ?KioskDevice + { + $uuid = request()->cookie(self::COOKIE_NAME); + + if (! $uuid) { + return null; + } + + return KioskDevice::query() + ->where('uuid', $uuid) + ->first(); + } + + protected function createDevice(): KioskDevice + { + $attempts = 0; + + do { + $attempts++; + try { + $device = KioskDevice::query()->create([ + 'uuid' => (string) Str::uuid(), + 'pairing_code' => KioskDevice::generatePairingCode(), + 'user_agent' => Str::limit((string) request()->userAgent(), 250, ''), + 'last_ip' => request()->ip(), + 'expires_at' => now()->addMinutes(KioskDevice::PAIRING_TTL_MINUTES), + ]); + + Cookie::queue(Cookie::make( + self::COOKIE_NAME, + $device->uuid, + 60 * 24 * 30, + '/', + null, + request()->isSecure(), + true, + false, + 'lax', + )); + + return $device; + } catch (QueryException $e) { + throw_if($attempts >= 5, $e); + } + } while (true); + } + + protected function rotateCode(KioskDevice $device): void + { + $attempts = 0; + + do { + $attempts++; + try { + $device->forceFill([ + 'pairing_code' => KioskDevice::generatePairingCode(), + 'expires_at' => now()->addMinutes(KioskDevice::PAIRING_TTL_MINUTES), + ])->save(); + + return; + } catch (QueryException $e) { + throw_if($attempts >= 5, $e); + } + } while (true); + } + + protected function syncFromDevice(KioskDevice $device): void + { + $this->deviceId = $device->id; + $this->pairingCode = (string) $device->pairing_code; + $this->expiresAt = $device->expires_at?->toIso8601String(); + $this->expired = false; + unset($this->qrSvg); + } + + protected function loginAndRedirect(KioskDevice $device): mixed + { + Auth::login($device->user); + session()->regenerate(true); + $device->user->switchTeam($device->team); + session(['kiosk_device_id' => $device->id]); + + return $this->redirect( + route('kiosk.calendar', ['current_team' => $device->team->slug]), + navigate: false, + ); + } +}; diff --git "a/resources/views/pages/kiosk/\342\232\241lists/lists.blade.php" "b/resources/views/pages/kiosk/\342\232\241lists/lists.blade.php" new file mode 100644 index 0000000..6a78ce6 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241lists/lists.blade.php" @@ -0,0 +1,9 @@ +
+
+ + {{ __('Lists') }} + + {{ __('Shared lists will live here.') }} + +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241lists/lists.php" "b/resources/views/pages/kiosk/\342\232\241lists/lists.php" new file mode 100644 index 0000000..80b1f7b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241lists/lists.php" @@ -0,0 +1,9 @@ +create(); + + actingAs($user) + ->get(route('kiosk.lists')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.lists') + ->assertOk(); +})->group('smoke'); diff --git "a/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.blade.php" "b/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.blade.php" new file mode 100644 index 0000000..ec35fe1 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.blade.php" @@ -0,0 +1,9 @@ +
+
+ + {{ __('Meal Planning') }} + + {{ __('Meal plans and dinner ideas will live here.') }} + +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.php" "b/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.php" new file mode 100644 index 0000000..80b1f7b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241meal-planning/meal-planning.php" @@ -0,0 +1,9 @@ +create(); + + actingAs($user) + ->get(route('kiosk.meal-planning')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.meal-planning') + ->assertOk(); +})->group('smoke'); diff --git "a/resources/views/pages/kiosk/\342\232\241pair/pair.blade.php" "b/resources/views/pages/kiosk/\342\232\241pair/pair.blade.php" new file mode 100644 index 0000000..2c85a31 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241pair/pair.blade.php" @@ -0,0 +1,61 @@ +
+ + @if ($expired) +
+ + {{ __('Pairing code expired') }} + + {{ __('Refresh the kiosk display and scan the new code.') }} + +
+ @elseif ($paired) +
+ + {{ __('Device paired') }} + {{ __('The display will load shortly.') }} +
+ @else + {{ __('Pair this display') }} + + {{ __('Code') }}: {{ $code }} + + + + {{ $this->deviceLabel }} + {{ $device->last_ip }} + + +
+ + + @if ($this->teams->count() > 1) + + + @foreach ($this->teams as $team) + + @endforeach + + @else + + + {{ __('Pairing to') }} {{ $this->teams->first()?->name }} + + @endif + +
+ + {{ __('Cancel') }} + + + {{ __('Pair display') }} + +
+ + @endif +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241pair/pair.php" "b/resources/views/pages/kiosk/\342\232\241pair/pair.php" new file mode 100644 index 0000000..94bcd45 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241pair/pair.php" @@ -0,0 +1,128 @@ +where('pairing_code', $code) + ->pending() + ->first(); + + if (! $device) { + $this->code = $code; + $this->expired = true; + $this->device = new KioskDevice; + + return; + } + + $this->code = $code; + $this->device = $device; + + $teams = $this->teams; + if ($teams->count() === 1) { + $this->teamId = $teams->first()->id; + } elseif (Auth::user()->current_team_id && $teams->contains('id', Auth::user()->current_team_id)) { + $this->teamId = Auth::user()->current_team_id; + } + } + + /** @return Collection */ + #[Computed] + public function teams(): Collection + { + return Auth::user()->teams()->orderBy('name')->get(); + } + + #[Computed] + public function deviceLabel(): string + { + $ua = (string) $this->device->user_agent; + + if ($ua === '') { + return __('Unknown device'); + } + + return match (true) { + str_contains($ua, 'iPhone') => 'iPhone', + str_contains($ua, 'iPad') => 'iPad', + str_contains($ua, 'Android') => 'Android device', + str_contains($ua, 'Macintosh') => 'Mac', + str_contains($ua, 'Windows') => 'Windows PC', + str_contains($ua, 'Linux') => 'Linux device', + default => __('Web browser'), + }; + } + + public function approve(): void + { + if ($this->expired) { + return; + } + + $validated = $this->validate([ + 'name' => ['nullable', 'string', 'max:60'], + 'teamId' => ['required', 'integer', 'in:' . $this->teams->pluck('id')->implode(',')], + ]); + + $name = trim((string) $validated['name']) ?: null; + + $affected = KioskDevice::query() + ->whereKey($this->device->id) + ->whereNull('paired_at') + ->where('expires_at', '>', now()) + ->where('pairing_code', $this->code) + ->update([ + 'user_id' => Auth::id(), + 'team_id' => $validated['teamId'], + 'name' => $name, + 'paired_at' => now(), + 'expires_at' => null, + 'pairing_code' => null, + 'updated_at' => now(), + ]); + + if ($affected !== 1) { + $this->expired = true; + + return; + } + + $this->paired = true; + } + + public function reject(): void + { + if ($this->expired) { + return; + } + + KioskDevice::query() + ->whereKey($this->device->id) + ->whereNull('paired_at') + ->delete(); + + $this->expired = true; + } +}; diff --git "a/resources/views/pages/kiosk/\342\232\241pair/pair.test.php" "b/resources/views/pages/kiosk/\342\232\241pair/pair.test.php" new file mode 100644 index 0000000..3103fd9 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241pair/pair.test.php" @@ -0,0 +1,257 @@ +assertOk() + ->assertCookie('kiosk_device_uuid'); + + expect(KioskDevice::query()->first())->not->toBeNull() + ->pairing_code->toHaveLength(8) + ->paired_at->toBeNull() + ->expires_at->not->toBeNull(); +}); + +test('the same browser keeps its pairing row on repeat visits', function (): void { + expect(KioskDevice::query()->count())->toBe(0); + + // generate device code + get('/kiosk'); + + $device = KioskDevice::query()->first(); + $cookie = Cookie::queued('kiosk_device_uuid')->getValue(); + expect($device->uuid)->toBe($cookie); + + // visit again and see same code + withCookie('kiosk_device_uuid', $cookie) + ->get('/kiosk') + ->assertOk() + ->assertSee($device->pairing_code); + + expect(KioskDevice::query()->count())->toBe(1); +}); + +test('phone confirm route requires authentication', function (): void { + $device = KioskDevice::factory()->pending()->create(); + + get(route('kiosk.pair', ['code' => $device->pairing_code])) + ->assertRedirect(route('login')); +}); + +test('phone visiting an expired code sees the expired view', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->expired()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->assertSet('expired', true) + ->assertSee(__('Pairing code expired')); +}); + +test('phone visiting an unknown code sees the expired view', function (): void { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.pair', ['code' => 'NOPECODE']) + ->assertSet('expired', true); +}); + +test('phone can approve a pending device and mark it paired', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->set('name', 'Kitchen TV') + ->set('teamId', $user->currentTeam->id) + ->call('approve') + ->assertSet('paired', true); + + expect($device->refresh()) + ->paired_at->not->toBeNull() + ->expires_at->toBeNull() + ->pairing_code->toBeNull() + ->user_id->toBe($user->id) + ->team_id->toBe($user->currentTeam->id) + ->name->toBe('Kitchen TV'); +}); + +test('phone cannot approve a team they are not a member of', function (): void { + $user = User::factory()->create(); + $otherTeam = Team::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + Livewire::actingAs($user) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->set('teamId', $otherTeam->id) + ->call('approve') + ->assertHasErrors(['teamId']); + + expect($device->fresh()) + ->paired_at->toBeNull(); +}); + +test('once a phone approves the device poll logs in and redirects', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + $component = Livewire::withCookie('kiosk_device_uuid', $device->uuid) + ->test('pages::kiosk.index'); + + KioskDevice::query()->whereKey($device->id)->update([ + 'user_id' => $user->id, + 'team_id' => $user->currentTeam->id, + 'paired_at' => now(), + 'expires_at' => null, + 'pairing_code' => null, + ]); + + $component->call('check') + ->assertRedirect(route('kiosk.calendar', ['current_team' => $user->currentTeam->slug])); + + expect(Auth::check())->toBeTrue() + ->and(Auth::id())->toBe($user->id) + ->and(session('kiosk_device_id'))->toBe($device->id); +}); + +test('concurrent approve only succeeds once', function (): void { + $userA = User::factory()->create(); + $userB = User::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + Livewire::actingAs($userA) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->set('teamId', $userA->currentTeam->id) + ->call('approve') + ->assertSet('paired', true); + + Livewire::actingAs($userB) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->set('teamId', $userB->currentTeam->id) + ->call('approve') + ->assertSet('expired', true) + ->assertSet('paired', false); + + expect($device->refresh()) + ->user_id->toBe($userA->id) + ->team_id->toBe($userA->currentTeam->id); +}); + +test('approve fails if pairing_code rotates between mount and approve', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + $phone = Livewire::actingAs($user) + ->test('pages::kiosk.pair', ['code' => $device->pairing_code]) + ->set('teamId', $user->currentTeam->id); + + KioskDevice::query()->whereKey($device->id)->update([ + 'pairing_code' => KioskDevice::generatePairingCode(), + ]); + + $phone + ->call('approve') + ->assertSet('expired', true); + + expect($device->fresh()) + ->paired_at->toBeNull(); +}); + +test('paired kiosk session cannot access dashboard', function (): void { + $user = User::factory()->create(); + + actingAs($user) + ->withSession(['kiosk_device_id' => 99]) + ->get(route('dashboard')) + ->assertForbidden(); +}); + +test('paired kiosk session can access kiosk pages', function (): void { + $user = User::factory()->create(); + + actingAs($user) + ->withSession(['kiosk_device_id' => 99]) + ->get(route('kiosk.calendar')) + ->assertOk(); +}); + +test('paired kiosk session can access root /kiosk after its device row is deleted', function (): void { + $user = User::factory()->create(); + + actingAs($user) + ->withSession(['kiosk_device_id' => 99]) + ->get('/kiosk') + ->assertOk(); +}); + +test('paired kiosk session can hit livewire update endpoint', function (): void { + $user = User::factory()->create(); + $updatePath = EndpointResolver::updatePath(); + + $response = $this->actingAs($user)->withSession(['kiosk_device_id' => 99]) + ->post($updatePath, []); + + expect($response->status())->not->toBe(403); +}); + +test('normal user session is not restricted', function (): void { + $user = User::factory()->create(); + + actingAs($user) + ->get(route('dashboard')) + ->assertOk(); +}); + +test('deleting a paired device returns it to QR on next visit', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->paired()->create([ + 'uuid' => 'fixed-uuid', + 'user_id' => $user->id, + 'team_id' => $user->currentTeam->id, + ]); + + $device->delete(); + + withCookie('kiosk_device_uuid', 'fixed-uuid') + ->get('/kiosk') + ->assertOk(); + + expect(KioskDevice::query()->count())->toBe(1); + expect(KioskDevice::query()->first()) + ->uuid->not->toBe('fixed-uuid') + ->paired_at->toBeNull(); +}); + +test('session id rotates after pair-completion login', function (): void { + $user = User::factory()->create(); + $device = KioskDevice::factory()->pending()->create(); + + session()->put('marker', 'before-pair'); + $sessionIdBefore = session()->getId(); + + $component = Livewire::withCookie('kiosk_device_uuid', $device->uuid) + ->test('pages::kiosk.index'); + + KioskDevice::query()->whereKey($device->id)->update([ + 'user_id' => $user->id, + 'team_id' => $user->currentTeam->id, + 'paired_at' => now(), + 'expires_at' => null, + 'pairing_code' => null, + ]); + + $component->call('check'); + + expect(session()->getId())->not->toBe($sessionIdBefore); +}); diff --git "a/resources/views/pages/kiosk/\342\232\241routines/routines.blade.php" "b/resources/views/pages/kiosk/\342\232\241routines/routines.blade.php" new file mode 100644 index 0000000..c1cc933 --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241routines/routines.blade.php" @@ -0,0 +1,9 @@ +
+
+ + {{ __('Routines') }} + + {{ __('Routine tracking will live here.') }} + +
+
diff --git "a/resources/views/pages/kiosk/\342\232\241routines/routines.php" "b/resources/views/pages/kiosk/\342\232\241routines/routines.php" new file mode 100644 index 0000000..80b1f7b --- /dev/null +++ "b/resources/views/pages/kiosk/\342\232\241routines/routines.php" @@ -0,0 +1,9 @@ +create(); + + actingAs($user) + ->get(route('kiosk.routines')) + ->assertOk(); + + Livewire::actingAs($user) + ->test('pages::kiosk.routines') + ->assertOk(); +})->group('smoke'); diff --git a/routes/kiosk.php b/routes/kiosk.php new file mode 100644 index 0000000..b8bcd57 --- /dev/null +++ b/routes/kiosk.php @@ -0,0 +1,25 @@ +name('kiosk.index'); + +Route::livewire('kiosk/pair/{code}', 'pages::kiosk.pair') + ->middleware(['auth', 'verified']) + ->name('kiosk.pair'); + +Route::prefix('{current_team}/kiosk') + ->middleware(['auth', 'verified', EnsureTeamMembership::class]) + ->name('kiosk') + ->group(function (): void { + Route::livewire('configure/preview', 'pages::kiosk.configure.preview')->name('.configure.preview'); + Route::livewire('configure/calendar', 'pages::kiosk.configure.calendar')->name('.configure.calendar'); + Route::livewire('configure/settings', 'pages::kiosk.configure.settings')->name('.configure.settings'); + + Route::livewire('calendar', 'pages::kiosk.calendar')->name('.calendar'); + Route::livewire('routines', 'pages::kiosk.routines')->name('.routines'); + Route::livewire('chore-chart', 'pages::kiosk.chore-chart')->name('.chore-chart'); + Route::livewire('lists', 'pages::kiosk.lists')->name('.lists'); + Route::livewire('meal-planning', 'pages::kiosk.meal-planning')->name('.meal-planning'); + }); diff --git a/routes/web.php b/routes/web.php index 340ee19..eb6c48f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,5 +17,6 @@ require __DIR__ . '/admin.php'; require __DIR__ . '/inventory.php'; +require __DIR__ . '/kiosk.php'; require __DIR__ . '/recipes.php'; require __DIR__ . '/settings.php'; diff --git a/tests/Unit/Actions/FetchCalendarEventsTest.php b/tests/Unit/Actions/FetchCalendarEventsTest.php new file mode 100644 index 0000000..69bdb58 --- /dev/null +++ b/tests/Unit/Actions/FetchCalendarEventsTest.php @@ -0,0 +1,49 @@ +create(); + $team->members()->attach( + User::factory()->create(['email' => 'zoro@example.com']), + ['role' => TeamRole::Member->value], + ); + $team->members()->attach( + User::factory()->create(['email' => 'nami@example.com']), + ['role' => TeamRole::Member->value], + ); + + $feed = CalendarFeed::factory()->for($team)->create([ + 'name' => 'Crew Calendar', + 'url' => 'https://example.com/calendar.ics', + ]); + + $events = resolve(FetchCalendarEvents::class)->parse( + ics: <<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Sunny//Calendar Test//EN +BEGIN:VEVENT +UID:crew-meeting@example.com +DTSTAMP:20260501T120000Z +DTSTART:20260505T150000Z +DTEND:20260505T160000Z +SUMMARY:Crew Meeting +ATTENDEE;PARTSTAT=ACCEPTED:mailto:zoro@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:sanji@example.com +END:VEVENT +END:VCALENDAR +ICS, + feed: $feed, + from: CarbonImmutable::parse('2026-05-05', 'America/Chicago'), + days: 1, + ); + + expect($events)->toHaveCount(1) + ->and($events->first()['response_status'])->toBe('ACCEPTED'); +});