Skip to content

Commit 169c38e

Browse files
committed
✨ Add BinDays and Calendar components with configuration types for waste collection and holiday management
1 parent fb68925 commit 169c38e

5 files changed

Lines changed: 354 additions & 0 deletions

File tree

src/components/app.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Aqi } from './aqi'
2+
import { BinDays } from './bin-days'
3+
import { Calendar } from './calendar'
24
import { Clock } from './clock'
35
import Compliments from './compliments'
46
import { HumanizeDuration } from './humanize-duration'
@@ -87,6 +89,60 @@ const App = () => (
8789
refreshIntervalSeconds: 30
8890
}}
8991
/>
92+
93+
<BinDays
94+
className="w-full xl:w-4/5"
95+
config={{
96+
collections: [
97+
{
98+
name: 'Food',
99+
dayOfWeek: 3,
100+
frequency: 'weekly',
101+
weekOffset: 0,
102+
referenceDate: '2024-12-03',
103+
accent: 'oklch(66.6% 0.179 58.318)'
104+
},
105+
{
106+
name: 'Recycling',
107+
dayOfWeek: 3,
108+
frequency: 'biweekly',
109+
weekOffset: 0,
110+
referenceDate: '2024-12-03',
111+
accent: 'oklch(72.3% 0.219 149.579)'
112+
},
113+
{
114+
name: 'Paper',
115+
dayOfWeek: 3,
116+
frequency: 'biweekly',
117+
weekOffset: 1,
118+
referenceDate: '2024-12-03',
119+
accent: 'oklch(54.6% 0.245 262.881)'
120+
},
121+
{
122+
name: 'General',
123+
dayOfWeek: 3,
124+
frequency: 'biweekly',
125+
weekOffset: 1,
126+
referenceDate: '2024-12-03',
127+
accent: 'oklch(87% 0 0)'
128+
}
129+
],
130+
holidayExceptions: [
131+
{ originalDate: '2025-12-25', revisedDate: '2025-12-27' },
132+
{ originalDate: '2025-12-26', revisedDate: '2025-12-29' },
133+
{ originalDate: '2025-12-29', revisedDate: '2025-12-30' },
134+
{ originalDate: '2025-12-30', revisedDate: '2025-12-31' },
135+
{ originalDate: '2025-12-31', revisedDate: '2026-01-02' },
136+
{ originalDate: '2026-01-01', revisedDate: '2026-01-03' },
137+
{ originalDate: '2026-01-02', revisedDate: '2026-01-05' },
138+
{ originalDate: '2026-01-05', revisedDate: '2026-01-06' },
139+
{ originalDate: '2026-01-06', revisedDate: '2026-01-07' },
140+
{ originalDate: '2026-01-07', revisedDate: '2026-01-08' },
141+
{ originalDate: '2026-01-08', revisedDate: '2026-01-09' },
142+
{ originalDate: '2026-01-09', revisedDate: '2026-01-10' }
143+
],
144+
}}
145+
/>
90146
</div>
91147

92148
{/* Row 2, Column 2: Top-right aligned - WeatherWidget */}
@@ -106,6 +162,28 @@ const App = () => (
106162
destinationStationCode: 'VIC'
107163
}}
108164
/>
165+
166+
<Calendar
167+
className="w-full xl:w-4/5"
168+
config={{
169+
holidays: [
170+
// 2025
171+
'2025-12-25', // Christmas Day
172+
'2025-12-26', // Boxing Day
173+
'2025-12-04',
174+
// 2026
175+
'2026-01-01', // New Year's Day
176+
'2026-04-03', // Good Friday
177+
'2026-04-06', // Easter Monday
178+
'2026-05-04', // Early May Bank Holiday
179+
'2026-05-25', // Spring Bank Holiday
180+
'2026-08-31', // Summer Bank Holiday
181+
'2026-12-25', // Christmas Day
182+
'2026-12-28' // Boxing Day (substitute, as 26th is Saturday)
183+
]
184+
}}
185+
/>
186+
109187
</div>
110188

111189
{/* Row 4: Full width merged column */}

src/components/bin-days/index.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { addWeeks, format, isSameDay, nextDay, parseISO, startOfDay, subDays } from 'date-fns'
2+
import { useEffect, useMemo, useState } from 'react'
3+
import type { BinDaysConfig, CollectionType, HolidayException } from './types'
4+
import { id } from 'date-fns/locale'
5+
6+
type BinDaysProps = {
7+
config: BinDaysConfig
8+
className?: string
9+
}
10+
11+
const applyHolidayException = (date: Date, exceptions: HolidayException[] = []): Date => {
12+
const dateStr = format(date, 'yyyy-MM-dd')
13+
const exception = exceptions.find((ex) => ex.originalDate === dateStr)
14+
return exception ? startOfDay(parseISO(exception.revisedDate)) : date
15+
}
16+
17+
const calculateNextCollection = (
18+
collection: CollectionType,
19+
now: Date,
20+
holidayExceptions: HolidayException[] = []
21+
): Date => {
22+
const reference = startOfDay(parseISO(collection.referenceDate))
23+
const today = startOfDay(now)
24+
25+
// Find next occurrence of the day of week
26+
let candidate =
27+
isSameDay(today, reference) || today < reference
28+
? reference
29+
: today.getDay() === collection.dayOfWeek
30+
? today
31+
: nextDay(today, collection.dayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6)
32+
33+
if (collection.frequency === 'weekly') {
34+
return applyHolidayException(candidate, holidayExceptions)
35+
}
36+
37+
// For biweekly, need to check if we're in the right week
38+
const weeksSinceReference = Math.floor(
39+
(candidate.getTime() - reference.getTime()) / (7 * 24 * 60 * 60 * 1000)
40+
)
41+
42+
const isCorrectWeek = weeksSinceReference % 2 === collection.weekOffset
43+
44+
if (!isCorrectWeek) {
45+
candidate = addWeeks(candidate, 1)
46+
}
47+
48+
return applyHolidayException(candidate, holidayExceptions)
49+
}
50+
51+
export const BinDays = ({ config: widgetConfig, className = '' }: BinDaysProps) => {
52+
const { collections, holidayExceptions, refreshIntervalSeconds = 60 * 60 } = widgetConfig
53+
const [now, setNow] = useState(new Date())
54+
55+
useEffect(() => {
56+
const interval = setInterval(() => {
57+
setNow(new Date())
58+
}, refreshIntervalSeconds * 1000)
59+
60+
return () => clearInterval(interval)
61+
}, [refreshIntervalSeconds])
62+
63+
const nextCollections = useMemo(() => {
64+
// Calculate collection dates (subtract 1 day for Tuesday night pickup)
65+
const collectionsWithDates = collections
66+
.map((collection) => {
67+
const actualCollectionDate = calculateNextCollection(collection, now, holidayExceptions)
68+
const displayDate = subDays(actualCollectionDate, 1) // Show as Tuesday night
69+
const daysUntil = Math.ceil(
70+
(displayDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
71+
)
72+
73+
return {
74+
...collection,
75+
displayDate,
76+
daysUntil
77+
}
78+
})
79+
.sort((a, b) => a.displayDate.getTime() - b.displayDate.getTime())
80+
81+
// Get the earliest date
82+
if (collectionsWithDates.length === 0) return []
83+
84+
const earliestDate = collectionsWithDates[0].displayDate.getTime()
85+
86+
// Return all collections that share the earliest date
87+
return collectionsWithDates.filter((c) => c.displayDate.getTime() === earliestDate)
88+
}, [collections, now, holidayExceptions])
89+
90+
if (nextCollections.length === 0) {
91+
return null
92+
}
93+
94+
const daysUntil = nextCollections[0].daysUntil
95+
const displayDate = nextCollections[0].displayDate
96+
97+
return (
98+
<div className={`flex flex-col items-center justify-center gap-4 p-4 ${className}`} id="bin-days">
99+
{/* Header */}
100+
<div className="font-light text-white/60 text-lg uppercase tracking-wide">
101+
Next bin day
102+
</div>
103+
104+
{/* Date info */}
105+
<div className="flex flex-col items-center gap-1">
106+
<div className="font-light text-white text-2xl">
107+
{format(displayDate, 'EEEE MMM dd')}
108+
</div>
109+
<div className="font-light text-white/60 text-xl">
110+
{daysUntil === 0
111+
? 'Tonight'
112+
: daysUntil === 1
113+
? 'Tomorrow'
114+
: `in ${daysUntil} days`}
115+
</div>
116+
</div>
117+
118+
{/* Icons row */}
119+
<div className="flex items-center gap-6">
120+
{nextCollections.map((collection) => (
121+
<div key={collection.name} className="flex flex-col items-center gap-2">
122+
<svg
123+
xmlns="http://www.w3.org/2000/svg"
124+
width="48"
125+
height="48"
126+
viewBox="0 0 24 24"
127+
fill="none"
128+
stroke={collection.accent}
129+
strokeWidth="2"
130+
strokeLinecap="round"
131+
strokeLinejoin="round"
132+
>
133+
<path d="M10 11v6" />
134+
<path d="M14 11v6" />
135+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
136+
<path d="M3 6h18" />
137+
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
138+
</svg>
139+
<div className="font-light text-white/80 text-sm text-center">
140+
{collection.name}
141+
</div>
142+
</div>
143+
))}
144+
</div>
145+
</div>
146+
)
147+
}

src/components/bin-days/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type CollectionFrequency = 'weekly' | 'biweekly'
2+
3+
export type CollectionType = {
4+
name: string
5+
dayOfWeek: number // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
6+
frequency: CollectionFrequency
7+
weekOffset: 0 | 1 // For biweekly: which week in the cycle (0 or 1)
8+
referenceDate: string // ISO date string (YYYY-MM-DD) for a known collection date
9+
accent: string // Color for the trash icon
10+
}
11+
12+
export type HolidayException = {
13+
originalDate: string // ISO date string (YYYY-MM-DD)
14+
revisedDate: string // ISO date string (YYYY-MM-DD)
15+
}
16+
17+
export type BinDaysConfig = {
18+
collections: CollectionType[]
19+
holidayExceptions?: HolidayException[]
20+
refreshIntervalSeconds?: number
21+
}

src/components/calendar/index.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
addDays,
3+
endOfMonth,
4+
format,
5+
isSameDay,
6+
isSameMonth,
7+
isToday,
8+
startOfMonth,
9+
startOfWeek
10+
} from 'date-fns'
11+
import { useMemo, useState } from 'react'
12+
import { cn } from '@/utils'
13+
import type { CalendarConfig } from './types'
14+
15+
type CalendarProps = {
16+
config?: CalendarConfig
17+
className?: string
18+
}
19+
20+
export const Calendar = ({ config, className = '' }: CalendarProps) => {
21+
const [now] = useState(new Date())
22+
const holidays = config?.holidays || []
23+
const weekStartsOn = config?.weekStartsOn ?? 1
24+
25+
const days = useMemo(() => {
26+
const monthStart = startOfMonth(now)
27+
const monthEnd = endOfMonth(now)
28+
const calendarStart = startOfWeek(monthStart, { weekStartsOn })
29+
30+
const daysArray: Date[] = []
31+
let currentDay = calendarStart
32+
33+
// Generate days until we cover the entire month
34+
while (currentDay <= monthEnd || daysArray.length % 7 !== 0) {
35+
daysArray.push(currentDay)
36+
currentDay = addDays(currentDay, 1)
37+
}
38+
39+
return daysArray
40+
.filter((day) => isSameMonth(day, now)) // Only show current month
41+
.map((day) => {
42+
const isWeekend = day.getDay() === 0 || day.getDay() === 6
43+
const isHoliday = holidays.includes(format(day, 'yyyy-MM-dd'))
44+
45+
return {
46+
date: day,
47+
isToday: isToday(day),
48+
isWeekend,
49+
isHoliday
50+
}
51+
})
52+
}, [now, holidays, weekStartsOn])
53+
54+
const weekDays =
55+
weekStartsOn === 0
56+
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
57+
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
58+
59+
return (
60+
<div className={cn('flex flex-col items-start justify-center gap-3 rounded-3xl p-4', className)}>
61+
{/* Month and Year */}
62+
<div className="font-light text-white text-2xl">{format(now, 'MMMM yyyy')}</div>
63+
64+
{/* Calendar Grid */}
65+
<div className="w-full">
66+
{/* Week day headers */}
67+
<div className="mb-2 grid grid-cols-7 gap-1">
68+
{weekDays.map((day) => (
69+
<div
70+
key={day}
71+
className="flex h-8 w-8 items-center justify-center font-light text-white/40 text-xs uppercase tracking-wide"
72+
>
73+
{day}
74+
</div>
75+
))}
76+
</div>
77+
78+
{/* Days grid */}
79+
<div className="grid grid-cols-7 gap-1">
80+
{days.map((day, index) => {
81+
const isTodayHoliday = day.isToday && (day.isHoliday || day.isWeekend)
82+
const isTodayRegular = day.isToday && !day.isHoliday && !day.isWeekend
83+
84+
return (
85+
<div
86+
key={index}
87+
className={cn(
88+
'flex h-8 w-8 items-center justify-center rounded-md text-sm font-light',
89+
isTodayHoliday && 'bg-red-500/40 font-bold text-red-100 is-today-holiday',
90+
isTodayRegular && 'bg-white/20 font-bold text-white is-today-regular',
91+
day.isHoliday && !day.isToday && 'bg-red-500/30 text-red-200 is-holiday-but-not-today',
92+
day.isWeekend && !day.isHoliday && !day.isToday && 'bg-red-500/15 text-red-300 is-weekend-but-not-today-and-not-holiday',
93+
!day.isHoliday && !day.isWeekend && !day.isToday && 'text-white is-not-holiday-and-not-weekend-and-not-today'
94+
)}
95+
>
96+
{format(day.date, 'd')}
97+
</div>
98+
)
99+
})}
100+
</div>
101+
</div>
102+
</div>
103+
)
104+
}

src/components/calendar/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type CalendarConfig = {
2+
holidays?: string[] // Array of ISO date strings (YYYY-MM-DD)
3+
weekStartsOn?: 0 | 1 // 0 = Sunday, 1 = Monday (default: 1)
4+
}

0 commit comments

Comments
 (0)