Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
47aafee
Just Keep Swimming 🐠
techenby May 14, 2026
ad59b22
Rector fixes
techenby May 14, 2026
8795366
Stub out other pages
techenby May 14, 2026
71551d4
Just Keep Swimming 🐠
techenby May 15, 2026
3c66e25
Remove timezone from factory
techenby May 15, 2026
5b36d4d
Just Keep Swimming 🐠
techenby May 15, 2026
9c68be0
Just Keep Swimming 🐠
techenby May 15, 2026
1418ff5
Remove calendar component from dashboard
techenby May 15, 2026
86a111d
Rector fixes
techenby May 15, 2026
193ac99
Dusting
techenby May 15, 2026
2da9c5a
Ignore Dusting commit in git blame
techenby May 15, 2026
f28390f
Add tests, move calendar feeds to teams
techenby May 15, 2026
b243c02
Rector fixes
techenby May 15, 2026
14c4d44
Dusting
techenby May 15, 2026
48e5c6f
Ignore Dusting commit in git blame
techenby May 15, 2026
d00233d
Move filters to dropdown
techenby May 15, 2026
79e1d64
Add month view
techenby May 15, 2026
dfc30a4
Add day view
techenby May 15, 2026
eeca4a5
Adjustments
techenby May 15, 2026
ea4a948
Add date and format to url, and adjust styles
techenby May 15, 2026
da91768
Change day view to hours
techenby May 15, 2026
3f295ab
Adjust styles
techenby May 15, 2026
4c0b89f
Change to button group
techenby May 15, 2026
a7b9e9d
Adjust month view to scroll, and hide more than two events on month view
techenby May 15, 2026
4a94c5a
Just Keep Swimming 🐠
techenby May 15, 2026
fd74418
Add collapsible sidebar
techenby May 25, 2026
e93966a
Add kiosk configure page
techenby May 25, 2026
fb7a557
Add calendar feeds for Strawhat's holidays
techenby May 25, 2026
3565b6d
Fix year formatting
techenby May 25, 2026
784790e
Add local screensize widget
techenby May 25, 2026
802df37
Start on configure page
techenby May 25, 2026
d048fb0
Add actions and form
techenby May 25, 2026
4ed6ca9
Clean up configure form
techenby May 25, 2026
d210de3
Add tabs
techenby May 25, 2026
00bbb13
Add flux components
techenby May 25, 2026
fbc75de
Just Keep Swimming 🐠
techenby May 26, 2026
2ec6416
Install Saloon and start on OpenWeather
techenby May 26, 2026
a7559ed
Start on OneCall
techenby May 26, 2026
6592ad0
Rector fixes
techenby May 26, 2026
cb4b250
Start on weather tile
techenby May 26, 2026
a84bc18
Just Keep Swimming 🐠
techenby May 26, 2026
8408840
Adjust css
techenby May 26, 2026
9f7129a
Fix issues
techenby May 26, 2026
f58a342
Fix other issues
techenby May 26, 2026
4662bad
Add button to open kiosk in new tab
techenby May 27, 2026
03ab85e
Move kiosk routes
techenby May 28, 2026
6e24b7b
Fix status assertions
techenby May 28, 2026
754fb3e
Add dot dot dot loading component
techenby Jun 2, 2026
58dfaf4
Add tv style authentication
techenby Jun 2, 2026
eb41b59
Add clock component with js rendering and truncate URLs
techenby Jun 2, 2026
f8b6819
Add polling
techenby Jun 2, 2026
5721db3
Add polling for slow refreshes
techenby Jun 2, 2026
4c03965
Add manual refresh button
techenby Jun 2, 2026
112d3e1
Add time range to calendar events
techenby Jun 2, 2026
7dc4a38
Dusting
techenby Jun 2, 2026
7d03bac
Ignore Dusting commit in git blame
techenby Jun 2, 2026
7c535d9
Remove device id
techenby Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

VITE_APP_NAME="${APP_NAME}"

# Services
MAPBOX_API_KEY=
OPENWEATHER_API_KEY=
3 changes: 3 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
24b8f32bcf7e24f999bd8763bc9baca607fad516
62f533191a8f08fb724b207b4e3f53f9ab2b35bb
e42a663c237250cc6e17eed50a7e3802d35e6a34
7dc4a38cd79c5cb74f6d0ababf1ded730ab1d030
167 changes: 167 additions & 0 deletions app/Actions/Calendars/FetchCalendarEvents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace App\Actions\Calendars;

use App\Models\CalendarFeed;
use Carbon\CarbonImmutable;
use DateTimeInterface;
use DateTimeZone;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\DateTimeParser;
use Sabre\VObject\Reader;

class FetchCalendarEvents
{
/**
* @return Collection<int, 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
* }>
*/
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<int, 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
* }>
*/
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<int, string> */
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';
}
}
17 changes: 17 additions & 0 deletions app/Actions/Kiosk/CreateFeed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Actions\Kiosk;

use App\Models\CalendarFeed;
use App\Models\Team;

class CreateFeed
{
/** @param array<string, mixed> $data */
public function handle(Team $team, array $data): CalendarFeed
{
return $team->calendarFeeds()->create($data);
}
}
18 changes: 18 additions & 0 deletions app/Actions/Kiosk/UpdateFeed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Actions\Kiosk;

use App\Models\CalendarFeed;

class UpdateFeed
{
/** @param array<string, mixed> $data */
public function handle(CalendarFeed $feed, array $data): CalendarFeed
{
$feed->update($data);

return $feed;
}
}
17 changes: 17 additions & 0 deletions app/Enums/CalendarColor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum CalendarColor: string
{
case Blue = '#2563eb';
case Green = '#16a34a';
case Red = '#dc2626';
case Purple = '#9333ea';
case Orange = '#ea580c';
case Cyan = '#0891b2';
case Gold = '#ca8a04';
case Indigo = '#4f46e5';
}
28 changes: 28 additions & 0 deletions app/Http/Integrations/OpenWeather/OpenWeatherConnector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Integrations\OpenWeather;

use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;

class OpenWeatherConnector extends Connector
{
use AcceptsJson;
use AlwaysThrowOnErrors;

public function resolveBaseUrl(): string
{
return 'https://api.openweathermap.org/data/3.0/';
}

protected function defaultQuery(): array
{
return [
'appid' => config('services.openweather.key'),
'units' => 'imperial',
];
}
}
33 changes: 33 additions & 0 deletions app/Http/Integrations/OpenWeather/Requests/OneCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Integrations\OpenWeather\Requests;

use Saloon\Enums\Method;
use Saloon\Http\Request;

class OneCall extends Request
{
protected Method $method = Method::GET;

public function __construct(
public readonly float $lat,
public readonly float $lon,
public readonly ?string $exclude = null
) {}

public function resolveEndpoint(): string
{
return '/onecall';
}

public function defaultQuery(): array
{
return array_filter([
'lat' => $this->lat,
'lon' => $this->lon,
'exclude' => $this->exclude,
]);
}
}
43 changes: 43 additions & 0 deletions app/Http/Middleware/RestrictKioskSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RestrictKioskSession
{
/** @param Closure(Request): (Response) $next */
public function handle(Request $request, Closure $next): Response
{
if (! $request->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';
}
}
Loading
Loading