The user wants me to read the first 100 lines of the schedule.ics file to understand its format, then continue with the planning.
{
"file_path": "/home/nemo/projects/personal/fosdem-playdate/schedule.ics",
"limit": 100
} 1→BEGIN:VCALENDAR
2→VERSION:2.0
3→PRODID:-//Pentabarf//Schedule 0.3//EN
4→CALSCALE:GREGORIAN
5→METHOD:PUBLISH
6→X-WR-CALDESC;VALUE=TEXT:iCal
7→X-WR-CALNAME;VALUE=TEXT:iCal
8→X-WR-TIMEZONE;VALUE=TEXT:Europe/Brussels
9→BEGIN:VEVENT
10→METHOD:PUBLISH
11→UID:6895@fosdem-2026@fosdem.org
12→TZID:Europe-Brussels
13→DTSTART:20260201T090000
14→DTEND:20260201T095000
15→SUMMARY:Free as in Burned Out: Who Really Pays for Open Source?
16→CLASS:PUBLIC
17→STATUS:CONFIRMED
18→CATEGORIES:Main Track
19→URL:https:/fosdem.org/2026/schedule/event/L3BK7S-free-as-in-burned-out/
20→LOCATION:Janson
21→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Marga Manterola":invalid:nomail
22→END:VEVENT
23→
24→BEGIN:VEVENT
25→METHOD:PUBLISH
26→UID:6907@fosdem-2026@fosdem.org
27→TZID:Europe-Brussels
28→DTSTART:20260201T090000
29→DTEND:20260201T095000
30→SUMMARY:Know Your Enemies: Live Exploit of a PHP Engine Security Breach
31→CLASS:PUBLIC
32→STATUS:CONFIRMED
33→CATEGORIES:Main Track
34→URL:https:/fosdem.org/2026/schedule/event/FVFEAM-know_your_enemies_live_exploit_of_a_php_engine_security_breach/
35→LOCATION:K.1.105 (La Fontaine)
36→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Alexandre Daubois":invalid:nomail
37→END:VEVENT
38→
39→BEGIN:VEVENT
40→METHOD:PUBLISH
41→UID:9789@fosdem-2026@fosdem.org
42→TZID:Europe-Brussels
43→DTSTART:20260201T090000
44→DTEND:20260201T090500
45→SUMMARY:Introduction to Local First & Welcome to our devroom
46→CLASS:PUBLIC
47→STATUS:CONFIRMED
48→CATEGORIES:Local-First, sync engines, CRDTs
49→URL:https:/fosdem.org/2026/schedule/event/TDGH7X-local-first/
50→LOCATION:K.3.201
51→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Niko Bonnieure":invalid:nomail
52→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Yousef El-Dardiry":invalid:nomail
53→END:VEVENT
54→
55→BEGIN:VEVENT
56→METHOD:PUBLISH
57→UID:9178@fosdem-2026@fosdem.org
58→TZID:Europe-Brussels
59→DTSTART:20260201T090000
60→DTEND:20260201T092500
61→SUMMARY:Domain crate update: developments, plans; what would you like to see?
62→CLASS:PUBLIC
63→STATUS:CONFIRMED
64→CATEGORIES:DNS
65→URL:https:/fosdem.org/2026/schedule/event/P8NRUB-domain-update/
66→LOCATION:K.3.401
67→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Philip Homburg":invalid:nomail
68→END:VEVENT
69→
70→BEGIN:VEVENT
71→METHOD:PUBLISH
72→UID:8224@fosdem-2026@fosdem.org
73→TZID:Europe-Brussels
74→DTSTART:20260201T090000
75→DTEND:20260201T092000
76→SUMMARY:Welcome to the SDR/DSP devroom
77→CLASS:PUBLIC
78→STATUS:CONFIRMED
79→CATEGORIES:Software Defined Radio(SDR)/Digital Signal Processing(DSP)
80→URL:https:/fosdem.org/2026/schedule/event/HGTYTH-welcome_to_the_sdrdsp_devroom/
81→LOCATION:K.3.601
82→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Jean-Michel Friedt":invalid:nomail
83→END:VEVENT
84→
85→BEGIN:VEVENT
86→METHOD:PUBLISH
87→UID:9071@fosdem-2026@fosdem.org
88→TZID:Europe-Brussels
89→DTSTART:20260201T090000
90→DTEND:20260201T093500
91→SUMMARY:An introduction to Plan 9
92→CLASS:PUBLIC
93→STATUS:CONFIRMED
94→CATEGORIES:Plan 9
95→URL:https:/fosdem.org/2026/schedule/event/U8J9UG-an-introduction-to-plan-9/
96→LOCATION:K.4.201
97→ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN="Revan":invalid:nomail
98→END:VEVENT
99→
100→BEGIN:VEVENT
<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>
Good, now I can see the actual ICS file format. This helps me understand:
Now let me continue with the Plan agent to create the implementation plan.
{
"subagent_type": "Plan",
"prompt": "Create an implementation plan for a FOSDEM 2026 Playdate Schedule Reader application.\n\n## SPECIFICATION (from SPEC.md):\n\n**App Title:** FOSDEM Schedule\n**Libraries Required:** CoreLibs/ui (gridview), CoreLibs/graphics, CoreLibs/timer, CoreLibs/nineslice\n\n### Key Features:\n\n1. **Timezone Validation:**\n - On init, calculate offset between local and GMT time\n - If offset is NOT 3600 seconds (1 hour for CET) → display warning and halt\n\n2. **Data Layer:**\n - Target File: schedule.ics in Data Disk\n - Sync Logic: Check if file exists, download from https://fosdem.org/2026/schedule/ical if missing\n - Refresh via System Menu \"Refresh Schedule\"\n \n3. **ICS Parsing:**\n - Parse VEVENT blocks with fields: DTSTART, DTEND, SUMMARY, CATEGORIES, URL, LOCATION\n - Date format: YYYYMMDDTHHMMSS (e.g., 20260201T090000) → convert to epoch seconds\n - Organize into: allEvents (flat list), eventsByCategory map, categoryNames (sorted alphabetically)\n - Handle comma-separated CATEGORIES (e.g., \"Local-First, sync engines, CRDTs\")\n\n4. **Logic & State:**\n - hiddenCategories table (saved to datastore)\n - \"Main Track\" should never be hidden\n - currentCategoryIndex, currentScrollPosition\n - Initial load: set to \"Main Track\", scroll to first event where DTEND > current time\n\n5. **UI Architecture:**\n - Main Schedule View using playdate.ui.gridview\n - Header (30px): White text on black rect showing \"< Category Name >\"\n - Sub-Header: Location of currently selected row\n - Cell format: [HH:MM] Summary (respect 24-hour time setting)\n - QR Code Overlay: triggered by A button, shows event URL as QR code\n\n6. **Input Mapping:**\n - Up/Down: navigate rows\n - Left/Right: switch categories (wrap around), reset scroll to active talk\n - Crank: category switching\n - A Button: show QR code\n - B Button (in QR view): dismiss overlay\n\n7. **System Menu:**\n - \"Refresh Schedule\" menu item\n - \"Devrooms\" filter view (toggle categories on/off)\n\n## ACTUAL ICS FILE FORMAT (from schedule.ics):\n\n```\nBEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Pentabarf//Schedule 0.3//EN\nX-WR-TIMEZONE;VALUE=TEXT:Europe/Brussels\nBEGIN:VEVENT\nMETHOD:PUBLISH\nUID:6895@fosdem-2026@fosdem.org\nTZID:Europe-Brussels\nDTSTART:20260201T090000\nDTEND:20260201T095000\nSUMMARY:Free as in Burned Out: Who Really Pays for Open Source?\nCLASS:PUBLIC\nSTATUS:CONFIRMED\nCATEGORIES:Main Track\nURL:https:/fosdem.org/2026/schedule/event/L3BK7S-free-as-in-burned-out/\nLOCATION:Janson\nATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN=\"Marga Manterola\":invalid:nomail\nEND:VEVENT\n\nBEGIN:VEVENT\n...\nCATEGORIES:Local-First, sync engines, CRDTs\n...\nEND:VEVENT\n```\n\n## PLAYDATE API AVAILABLE:\n\n- Time: playdate.getTime(), playdate.getGMTTime(), playdate.epochFromTime(), playdate.shouldDisplay24HourTime()\n- Files: playdate.file.exists(), playdate.file.open(), playdate.file.file:readline(), playdate.datastore.write/read()\n- Network: playdate.network.http.new(), :get(), :read(), :setRequestCompleteCallback()\n- Graphics: playdate.graphics (drawing, fonts, images), playdate.graphics.generateQRCode()\n- UI: playdate.ui.gridview with drawCell, setNumberOfRows, selectNextRow/selectPreviousRow, etc.\n- Input: inputHandlers with AButtonDown, BButtonDown, upButtonDown, leftButtonDown, cranked(), etc.\n- System Menu: playdate.getSystemMenu(), menu:addMenuItem()\n\nCreate a detailed implementation plan with:\n1. File structure for the project\n2. Implementation order (phases/steps)\n3. Key modules and their responsibilities\n4. Critical implementation details for each component\n5. Potential challenges or edge cases"
}Good. Now I have a comprehensive understanding of the project requirements. Let me create the detailed implementation plan.
---
# FOSDEM 2026 Playdate Schedule Reader - Implementation Plan
## Executive Summary
This document outlines the implementation plan for a Playdate application that allows FOSDEM 2026 attendees to browse the conference schedule on their Playdate device. The application will parse an ICS calendar file, organize events by category (devroom), and provide an intuitive navigation interface with QR code generation for event URLs.
---
## 1. Project File Structure
```
fosdem-schedule/
|-- source/
| |-- main.lua # Entry point, game loop, initialization
| |-- config.lua # Constants and configuration values
| |-- state.lua # Application state management
| |-- ui/
| | |-- scheduleView.lua # Main schedule gridview implementation
| | |-- qrOverlay.lua # QR code overlay component
| | |-- devroomsFilter.lua # Category filter view
| | |-- header.lua # Header rendering (category name)
| | |-- warningScreen.lua # Timezone warning screen
| |-- data/
| | |-- icsParser.lua # ICS file parsing logic
| | |-- eventStore.lua # Event data organization and access
| | |-- networkSync.lua # Network download functionality
| | |-- persistence.lua # Datastore read/write for preferences
| |-- utils/
| | |-- dateUtils.lua # Date parsing and formatting utilities
| | |-- timeUtils.lua # Timezone validation, epoch conversion
| |-- input/
| |-- inputHandlers.lua # Input handler definitions
|-- pdxinfo # Playdate metadata file
```
---
## 2. Implementation Phases
### Phase 1: Project Setup and Core Utilities (Foundation)
**Duration:** Initial setup
**Tasks:**
1. Create `pdxinfo` with application metadata
2. Implement `config.lua` with all constants
3. Implement `dateUtils.lua` for ICS date parsing (YYYYMMDDTHHMMSS format)
4. Implement `timeUtils.lua` for timezone validation and epoch conversion
**Key Implementation Details:**
**config.lua:**
```lua
-- Constants
Config = {
HEADER_HEIGHT = 30,
SUB_HEADER_HEIGHT = 20,
CELL_HEIGHT = 24,
CET_OFFSET_SECONDS = 3600, -- 1 hour in seconds
ICS_FILE_PATH = "schedule.ics",
ICS_URL = "https://fosdem.org/2026/schedule/ical",
MAIN_TRACK = "Main Track",
EPOCH_YEAR_2000_OFFSET = 946684800 -- Seconds from Unix epoch to Playdate epoch (Jan 1, 2000)
}
```
**dateUtils.lua - Critical parsing function:**
```lua
-- Parse ICS date format: YYYYMMDDTHHMMSS (e.g., 20260201T090000)
-- Returns a table compatible with playdate.epochFromTime()
function parseICSDate(dateStr)
local year = tonumber(dateStr:sub(1, 4))
local month = tonumber(dateStr:sub(5, 6))
local day = tonumber(dateStr:sub(7, 8))
local hour = tonumber(dateStr:sub(10, 11))
local minute = tonumber(dateStr:sub(12, 13))
local second = tonumber(dateStr:sub(14, 15))
return {
year = year,
month = month,
day = day,
hour = hour,
minute = minute,
second = second,
millisecond = 0
}
end
```
**timeUtils.lua - Timezone validation:**
```lua
-- Calculate offset between local and GMT time
-- Returns offset in seconds
function getTimezoneOffset()
local localTime = playdate.getTime()
local gmtTime = playdate.getGMTTime()
local localEpoch = playdate.epochFromTime(localTime)
local gmtEpoch = playdate.epochFromGMTTime(gmtTime)
return localEpoch - gmtEpoch
end
function isValidTimezone()
local offset = getTimezoneOffset()
return offset == Config.CET_OFFSET_SECONDS
end
```
---
### Phase 2: Data Layer Implementation
**Duration:** Core data handling
**Tasks:**
1. Implement `icsParser.lua` for parsing ICS files
2. Implement `eventStore.lua` for data organization
3. Implement `persistence.lua` for saving/loading hidden categories
4. Implement `networkSync.lua` for downloading ICS files
**icsParser.lua - Key parsing logic:**
```lua
-- Parse the ICS file and return a list of event tables
function parseICSFile(filePath)
local events = {}
local file = playdate.file.open(filePath, playdate.file.kFileRead)
if not file then
return nil, "Could not open file"
end
local currentEvent = nil
local line = file:readline()
while line do
line = trimWhitespace(line)
if line == "BEGIN:VEVENT" then
currentEvent = {}
elseif line == "END:VEVENT" then
if currentEvent then
table.insert(events, currentEvent)
end
currentEvent = nil
elseif currentEvent then
-- Parse key:value pairs
local key, value = parseICSLine(line)
if key and value then
if key == "DTSTART" or key == "DTEND" then
currentEvent[key] = parseICSDate(value)
currentEvent[key .. "_epoch"] = playdate.epochFromTime(currentEvent[key])
elseif key == "CATEGORIES" then
-- Handle comma-separated categories
currentEvent[key] = splitCategories(value)
else
currentEvent[key] = value
end
end
end
line = file:readline()
end
file:close()
return events
end
-- Split comma-separated categories
function splitCategories(categoriesStr)
local categories = {}
for cat in categoriesStr:gmatch("([^,]+)") do
table.insert(categories, trimWhitespace(cat))
end
return categories
end
```
**eventStore.lua - Data organization:**
```lua
EventStore = {}
function EventStore.init(events)
EventStore.allEvents = events
EventStore.eventsByCategory = {}
EventStore.categoryNames = {}
-- Build category map
for _, event in ipairs(events) do
local categories = event.CATEGORIES or {}
for _, category in ipairs(categories) do
if not EventStore.eventsByCategory[category] then
EventStore.eventsByCategory[category] = {}
table.insert(EventStore.categoryNames, category)
end
table.insert(EventStore.eventsByCategory[category], event)
end
end
-- Sort category names alphabetically
table.sort(EventStore.categoryNames)
-- Sort events within each category by start time
for category, catEvents in pairs(EventStore.eventsByCategory) do
table.sort(catEvents, function(a, b)
return a.DTSTART_epoch < b.DTSTART_epoch
end)
end
end
function EventStore.getEventsForCategory(category)
return EventStore.eventsByCategory[category] or {}
end
function EventStore.getCategoryIndex(categoryName)
for i, name in ipairs(EventStore.categoryNames) do
if name == categoryName then
return i
end
end
return 1
end
function EventStore.findFirstActiveEventIndex(category)
local events = EventStore.getEventsForCategory(category)
local currentEpoch = playdate.getSecondsSinceEpoch()
for i, event in ipairs(events) do
if event.DTEND_epoch > currentEpoch then
return i
end
end
return 1 -- Default to first event if all have ended
end
```
**networkSync.lua - Download logic:**
```lua
NetworkSync = {}
function NetworkSync.downloadSchedule(callback)
local http = playdate.network.http.new(
"fosdem.org",
443,
true, -- useSSL
"Download FOSDEM schedule for offline viewing"
)
http:setRequestCompleteCallback(function()
local status = http:getResponseStatus()
if status == 200 then
local data = http:read()
local file = playdate.file.open(
Config.ICS_FILE_PATH,
playdate.file.kFileWrite
)
if file then
file:write(data)
file:close()
callback(true)
else
callback(false, "Could not write file")
end
else
callback(false, "HTTP error: " .. status)
end
end)
http:get("/2026/schedule/ical")
end
function NetworkSync.scheduleExists()
return playdate.file.exists(Config.ICS_FILE_PATH)
end
```
**persistence.lua - Hidden categories storage:**
```lua
Persistence = {}
local HIDDEN_CATEGORIES_KEY = "hiddenCategories"
function Persistence.saveHiddenCategories(hiddenCategories)
playdate.datastore.write({ hidden = hiddenCategories }, HIDDEN_CATEGORIES_KEY)
end
function Persistence.loadHiddenCategories()
local data = playdate.datastore.read(HIDDEN_CATEGORIES_KEY)
if data and data.hidden then
-- Ensure "Main Track" is never hidden
data.hidden[Config.MAIN_TRACK] = nil
return data.hidden
end
return {}
end
```
---
### Phase 3: UI Components
**Duration:** UI implementation
**Tasks:**
1. Implement `warningScreen.lua` for timezone warning
2. Implement `header.lua` for category header display
3. Implement `scheduleView.lua` using gridview
4. Implement `qrOverlay.lua` for QR code display
5. Implement `devroomsFilter.lua` for category filtering
**warningScreen.lua:**
```lua
WarningScreen = {}
function WarningScreen.draw()
local gfx = playdate.graphics
gfx.clear(gfx.kColorWhite)
local message = "Timezone must be set to\nCET (+01:00)"
local width, height = gfx.getTextSize(message)
gfx.drawTextAligned(
message,
200, -- center x
120 - height / 2, -- center y
kTextAlignment.center
)
end
```
**scheduleView.lua - Main gridview implementation:**
```lua
import "CoreLibs/ui"
import "CoreLibs/graphics"
ScheduleView = {}
local gfx = playdate.graphics
function ScheduleView.init()
-- Create gridview with cell width 0 (full width) and appropriate height
ScheduleView.gridview = playdate.ui.gridview.new(0, Config.CELL_HEIGHT)
ScheduleView.gridview:setContentInset(0, 0, Config.HEADER_HEIGHT + Config.SUB_HEADER_HEIGHT, 0)
-- Override drawCell
function ScheduleView.gridview:drawCell(section, row, column, selected, x, y, width, height)
local events = ScheduleView.getCurrentEvents()
local event = events[row]
if not event then return end
-- Draw selection highlight
if selected then
gfx.fillRect(x, y, width, height)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
else
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
-- Format time based on user preference
local timeStr = ScheduleView.formatTime(event.DTSTART)
local displayText = "[" .. timeStr .. "] " .. event.SUMMARY
-- Draw with truncation if needed
gfx.drawTextInRect(displayText, x + 4, y + 2, width - 8, height - 4, nil, "...")
end
end
function ScheduleView.formatTime(timeTable)
if playdate.shouldDisplay24HourTime() then
return string.format("%02d:%02d", timeTable.hour, timeTable.minute)
else
local hour = timeTable.hour
local ampm = "am"
if hour >= 12 then
ampm = "pm"
if hour > 12 then hour = hour - 12 end
end
if hour == 0 then hour = 12 end
return string.format("%d:%02d %s", hour, timeTable.minute, ampm)
end
end
function ScheduleView.getCurrentEvents()
local category = EventStore.categoryNames[State.currentCategoryIndex]
return EventStore.getEventsForCategory(category)
end
function ScheduleView.updateRowCount()
local events = ScheduleView.getCurrentEvents()
ScheduleView.gridview:setNumberOfRows(#events)
end
function ScheduleView.scrollToActiveEvent()
local category = EventStore.categoryNames[State.currentCategoryIndex]
local activeIndex = EventStore.findFirstActiveEventIndex(category)
ScheduleView.gridview:setSelectedRow(activeIndex)
ScheduleView.gridview:scrollToRow(activeIndex)
end
function ScheduleView.draw()
local gfx = playdate.graphics
-- Draw header (black background, white text)
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(0, 0, 400, Config.HEADER_HEIGHT)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
local category = EventStore.categoryNames[State.currentCategoryIndex]
local headerText = "< " .. category .. " >"
gfx.drawTextAligned(headerText, 200, 8, kTextAlignment.center)
-- Draw sub-header (location of selected event)
gfx.setImageDrawMode(gfx.kDrawModeCopy)
local events = ScheduleView.getCurrentEvents()
local selectedRow = ScheduleView.gridview:getSelectedRow()
if events[selectedRow] then
local location = events[selectedRow].LOCATION or "Unknown location"
gfx.drawTextInRect(
location,
4,
Config.HEADER_HEIGHT + 2,
392,
Config.SUB_HEADER_HEIGHT - 4,
nil,
"..."
)
end
-- Draw gridview
ScheduleView.gridview:drawInRect(
0,
Config.HEADER_HEIGHT + Config.SUB_HEADER_HEIGHT,
400,
240 - Config.HEADER_HEIGHT - Config.SUB_HEADER_HEIGHT
)
end
```
**qrOverlay.lua:**
```lua
import "CoreLibs/qrcode"
QROverlay = {}
QROverlay.visible = false
QROverlay.qrImage = nil
function QROverlay.show(url)
QROverlay.visible = true
QROverlay.qrImage = nil
-- Generate QR code asynchronously
playdate.graphics.generateQRCode(url, 150, function(image, errorMessage)
if image then
QROverlay.qrImage = image
else
print("QR generation error: " .. (errorMessage or "unknown"))
end
end)
end
function QROverlay.hide()
QROverlay.visible = false
QROverlay.qrImage = nil
end
function QROverlay.draw()
if not QROverlay.visible then return end
local gfx = playdate.graphics
-- Draw white background
gfx.setColor(gfx.kColorWhite)
gfx.fillRect(50, 30, 300, 180)
gfx.setColor(gfx.kColorBlack)
gfx.drawRect(50, 30, 300, 180)
if QROverlay.qrImage then
-- Draw QR code centered
local w, h = QROverlay.qrImage:getSize()
QROverlay.qrImage:drawCentered(200, 100)
else
gfx.drawTextAligned("Generating QR...", 200, 100, kTextAlignment.center)
end
-- Draw dismiss instruction
gfx.drawTextAligned("Press B to close", 200, 190, kTextAlignment.center)
end
```
**devroomsFilter.lua:**
```lua
DevroomsFilter = {}
DevroomsFilter.visible = false
DevroomsFilter.gridview = nil
function DevroomsFilter.init()
DevroomsFilter.gridview = playdate.ui.gridview.new(0, 24)
function DevroomsFilter.gridview:drawCell(section, row, column, selected, x, y, width, height)
local gfx = playdate.graphics
local category = EventStore.categoryNames[row]
local isHidden = State.hiddenCategories[category] == true
local isMainTrack = category == Config.MAIN_TRACK
if selected then
gfx.fillRect(x, y, width, height)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
else
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
local checkbox = isHidden and "[ ]" or "[x]"
if isMainTrack then
checkbox = "[x]" -- Always checked, cannot be unchecked
end
gfx.drawText(checkbox .. " " .. category, x + 4, y + 4)
end
end
function DevroomsFilter.show()
DevroomsFilter.visible = true
DevroomsFilter.gridview:setNumberOfRows(#EventStore.categoryNames)
DevroomsFilter.gridview:setSelectedRow(1)
end
function DevroomsFilter.hide()
DevroomsFilter.visible = false
Persistence.saveHiddenCategories(State.hiddenCategories)
end
function DevroomsFilter.toggleSelectedCategory()
local row = DevroomsFilter.gridview:getSelectedRow()
local category = EventStore.categoryNames[row]
-- Prevent hiding Main Track
if category == Config.MAIN_TRACK then return end
if State.hiddenCategories[category] then
State.hiddenCategories[category] = nil
else
State.hiddenCategories[category] = true
end
end
function DevroomsFilter.draw()
if not DevroomsFilter.visible then return end
local gfx = playdate.graphics
gfx.clear(gfx.kColorWhite)
-- Header
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(0, 0, 400, 30)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
gfx.drawTextAligned("Devrooms Filter", 200, 8, kTextAlignment.center)
gfx.setImageDrawMode(gfx.kDrawModeCopy)
DevroomsFilter.gridview:drawInRect(0, 30, 400, 210)
end
```
---
### Phase 4: State Management and Input Handling
**Duration:** Integration
**Tasks:**
1. Implement `state.lua` for centralized state management
2. Implement `inputHandlers.lua` for all input handling
**state.lua:**
```lua
State = {}
-- Application states
State.LOADING = "loading"
State.TIMEZONE_ERROR = "timezone_error"
State.SCHEDULE_VIEW = "schedule_view"
State.QR_VIEW = "qr_view"
State.DEVROOMS_VIEW = "devrooms_view"
function State.init()
State.currentState = State.LOADING
State.currentCategoryIndex = 1
State.hiddenCategories = Persistence.loadHiddenCategories()
end
function State.setCategory(index)
-- Skip hidden categories
local visibleCategories = State.getVisibleCategories()
if #visibleCategories == 0 then return end
-- Wrap around
if index < 1 then
index = #visibleCategories
elseif index > #visibleCategories then
index = 1
end
-- Map visible index to actual category index
local visibleCategory = visibleCategories[index]
State.currentCategoryIndex = EventStore.getCategoryIndex(visibleCategory)
ScheduleView.updateRowCount()
ScheduleView.scrollToActiveEvent()
end
function State.getVisibleCategories()
local visible = {}
for _, category in ipairs(EventStore.categoryNames) do
if not State.hiddenCategories[category] then
table.insert(visible, category)
end
end
return visible
end
function State.nextCategory()
local visibleCategories = State.getVisibleCategories()
local currentCategory = EventStore.categoryNames[State.currentCategoryIndex]
for i, cat in ipairs(visibleCategories) do
if cat == currentCategory then
State.setCategory(i + 1)
return
end
end
end
function State.previousCategory()
local visibleCategories = State.getVisibleCategories()
local currentCategory = EventStore.categoryNames[State.currentCategoryIndex]
for i, cat in ipairs(visibleCategories) do
if cat == currentCategory then
State.setCategory(i - 1)
return
end
end
end
```
**inputHandlers.lua:**
```lua
InputHandlers = {}
InputHandlers.scheduleHandlers = {
upButtonDown = function()
ScheduleView.gridview:selectPreviousRow(false)
end,
downButtonDown = function()
ScheduleView.gridview:selectNextRow(false)
end,
leftButtonDown = function()
State.previousCategory()
end,
rightButtonDown = function()
State.nextCategory()
end,
AButtonDown = function()
local events = ScheduleView.getCurrentEvents()
local row = ScheduleView.gridview:getSelectedRow()
if events[row] and events[row].URL then
State.currentState = State.QR_VIEW
QROverlay.show(events[row].URL)
playdate.inputHandlers.push(InputHandlers.qrHandlers)
end
end,
cranked = function(change, acceleratedChange)
-- Use accelerated change for category switching
-- Accumulate crank rotation
InputHandlers.crankAccumulator = (InputHandlers.crankAccumulator or 0) + change
local threshold = 30 -- degrees per category switch
if InputHandlers.crankAccumulator > threshold then
State.nextCategory()
InputHandlers.crankAccumulator = 0
elseif InputHandlers.crankAccumulator < -threshold then
State.previousCategory()
InputHandlers.crankAccumulator = 0
end
end
}
InputHandlers.qrHandlers = {
BButtonDown = function()
QROverlay.hide()
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.pop()
end
}
InputHandlers.devroomsHandlers = {
upButtonDown = function()
DevroomsFilter.gridview:selectPreviousRow(false)
end,
downButtonDown = function()
DevroomsFilter.gridview:selectNextRow(false)
end,
AButtonDown = function()
DevroomsFilter.toggleSelectedCategory()
end,
BButtonDown = function()
DevroomsFilter.hide()
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.pop()
-- Ensure current category is still visible
local currentCategory = EventStore.categoryNames[State.currentCategoryIndex]
if State.hiddenCategories[currentCategory] then
State.setCategory(1)
end
end
}
InputHandlers.crankAccumulator = 0
```
---
### Phase 5: Main Application Integration
**Duration:** Final integration
**Tasks:**
1. Implement `main.lua` tying everything together
2. Set up System Menu items
3. Implement complete update loop
**main.lua:**
```lua
import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/ui"
import "CoreLibs/timer"
import "CoreLibs/nineslice"
import "CoreLibs/qrcode"
import "config"
import "state"
import "utils/dateUtils"
import "utils/timeUtils"
import "data/icsParser"
import "data/eventStore"
import "data/networkSync"
import "data/persistence"
import "ui/warningScreen"
import "ui/scheduleView"
import "ui/qrOverlay"
import "ui/devroomsFilter"
import "ui/header"
import "input/inputHandlers"
local gfx = playdate.graphics
-- Initialization
function setup()
-- Validate timezone
if not isValidTimezone() then
State.currentState = State.TIMEZONE_ERROR
return
end
-- Load or download schedule
if NetworkSync.scheduleExists() then
loadSchedule()
else
State.currentState = State.LOADING
gfx.clear()
gfx.drawTextAligned("Downloading schedule...", 200, 120, kTextAlignment.center)
NetworkSync.downloadSchedule(function(success, error)
if success then
loadSchedule()
else
-- Show error, maybe retry option
gfx.clear()
gfx.drawTextAligned("Download failed: " .. (error or "unknown"), 200, 120, kTextAlignment.center)
end
end)
end
-- Setup System Menu
local menu = playdate.getSystemMenu()
menu:addMenuItem("Refresh Schedule", function()
State.currentState = State.LOADING
NetworkSync.downloadSchedule(function(success, error)
if success then
loadSchedule()
end
end)
end)
menu:addMenuItem("Devrooms", function()
State.currentState = State.DEVROOMS_VIEW
DevroomsFilter.show()
playdate.inputHandlers.push(InputHandlers.devroomsHandlers)
end)
end
function loadSchedule()
local events, error = parseICSFile(Config.ICS_FILE_PATH)
if not events then
gfx.clear()
gfx.drawTextAligned("Parse error: " .. (error or "unknown"), 200, 120, kTextAlignment.center)
return
end
EventStore.init(events)
State.init()
ScheduleView.init()
DevroomsFilter.init()
-- Set initial category to Main Track
local mainTrackIndex = EventStore.getCategoryIndex(Config.MAIN_TRACK)
State.currentCategoryIndex = mainTrackIndex
ScheduleView.updateRowCount()
ScheduleView.scrollToActiveEvent()
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.push(InputHandlers.scheduleHandlers)
end
-- Main update loop
function playdate.update()
if State.currentState == State.TIMEZONE_ERROR then
WarningScreen.draw()
return
end
if State.currentState == State.LOADING then
-- Loading screen already drawn, just wait
return
end
if State.currentState == State.SCHEDULE_VIEW then
gfx.clear()
ScheduleView.draw()
end
if State.currentState == State.QR_VIEW then
gfx.clear()
ScheduleView.draw() -- Draw schedule in background
QROverlay.draw() -- Draw overlay on top
end
if State.currentState == State.DEVROOMS_VIEW then
DevroomsFilter.draw()
end
-- Required for gridview animations and QR code generation
playdate.timer.updateTimers()
end
-- Lifecycle callbacks
function playdate.gameWillTerminate()
Persistence.saveHiddenCategories(State.hiddenCategories)
end
function playdate.deviceWillSleep()
Persistence.saveHiddenCategories(State.hiddenCategories)
end
-- Start the app
setup()
```
---
## 3. Key Implementation Challenges and Solutions
### Challenge 1: ICS Date Parsing Without os.time
**Problem:** Playdate does not have `os.time()` for parsing arbitrary date strings.
**Solution:** Manually parse the `YYYYMMDDTHHMMSS` format character by character using string operations, then construct a table matching `playdate.getTime()` format for use with `playdate.epochFromTime()`.
### Challenge 2: Comma-Separated Categories
**Problem:** Events can belong to multiple categories (e.g., "Local-First, sync engines, CRDTs").
**Solution:** Split the CATEGORIES field on commas and add the event to each category's event list. This creates duplicate references, but Lua tables handle this efficiently.
### Challenge 3: Category Navigation with Hidden Categories
**Problem:** When switching categories with Left/Right buttons or crank, hidden categories must be skipped.
**Solution:** Maintain a list of visible categories derived from the full list minus hidden ones. Navigation operates on visible indices and maps back to actual category indices.
### Challenge 4: QR Code Generation is Asynchronous
**Problem:** `playdate.graphics.generateQRCode()` uses timers and requires time to complete.
**Solution:** Show a "Generating..." message while waiting. The callback updates the QR image when ready. Ensure `playdate.timer.updateTimers()` is called in the update loop.
### Challenge 5: Timezone Validation Using Epoch
**Problem:** Calculating timezone offset requires comparing local and GMT times.
**Solution:** Get both times, convert to epochs using `epochFromTime()` and `epochFromGMTTime()`, then subtract. The difference should be 3600 seconds for CET.
### Challenge 6: Network Request Handling
**Problem:** Network downloads are asynchronous and require proper callback handling.
**Solution:** Use state machine to show loading screen during download. Set up `setRequestCompleteCallback` to handle completion, check status code, then read and write data.
### Challenge 7: Persistent Storage for Preferences
**Problem:** Hidden categories should persist across sessions.
**Solution:** Use `playdate.datastore.write()` and `playdate.datastore.read()` to save/load a table of hidden categories. Always ensure "Main Track" is not in the hidden list.
---
## 4. Edge Cases to Handle
1. **Empty categories:** Some categories might have no events at certain times
2. **Very long event titles:** Use truncation with "..." in `drawTextInRect`
3. **Very long category names:** Ensure header handles overflow gracefully
4. **Network timeout/failure:** Provide clear error messages and retry option
5. **Missing LOCATION field:** Default to "Unknown location"
6. **Missing URL field:** Disable QR code button for events without URLs
7. **All categories hidden:** Prevent this by ensuring Main Track cannot be hidden
8. **ICS file format variations:** Handle missing optional fields gracefully
9. **Events spanning midnight:** Epoch-based comparisons handle this naturally
10. **Events with same start time:** Maintain stable sort order
---
## 5. Testing Considerations
1. Test timezone validation with different device timezone settings
2. Test ICS parsing with the actual 1014-event schedule
3. Test category switching with hidden categories
4. Test QR code generation for long URLs
5. Test network download failure scenarios
6. Test persistence across app restarts
7. Test crank sensitivity for category switching
8. Test gridview scrolling performance with many events
9. Test 12-hour vs 24-hour time display preference
---
### Critical Files for Implementation
- `/home/nemo/projects/personal/fosdem-playdate/source/main.lua` - Entry point, initialization, update loop, and system menu setup
- `/home/nemo/projects/personal/fosdem-playdate/source/data/icsParser.lua` - Critical ICS parsing logic including date format conversion and category splitting
- `/home/nemo/projects/personal/fosdem-playdate/source/ui/scheduleView.lua` - Core UI implementation using playdate.ui.gridview with custom cell rendering
- `/home/nemo/projects/personal/fosdem-playdate/source/state.lua` - Central state management including category navigation with hidden category handling
- `/home/nemo/projects/personal/fosdem-playdate/source/utils/timeUtils.lua` - Timezone validation and epoch conversion utilities critical for the app's core constraintagentId: a087679 (for resuming to continue this agent's work if needed)
The Plan agent has returned a comprehensive implementation plan. Let me summarize the key points for the user.
The Plan agent has created a comprehensive implementation plan for the FOSDEM 2026 Playdate Schedule Reader. Here's a summary:
fosdem-schedule/
├── source/
│ ├── main.lua # Entry point, game loop
│ ├── config.lua # Constants
│ ├── state.lua # App state management
│ ├── ui/
│ │ ├── scheduleView.lua # Main gridview
│ │ ├── qrOverlay.lua # QR code display
│ │ ├── devroomsFilter.lua # Category filter
│ │ └── warningScreen.lua # Timezone warning
│ ├── data/
│ │ ├── icsParser.lua # ICS file parsing
│ │ ├── eventStore.lua # Event organization
│ │ ├── networkSync.lua # Download logic
│ │ └── persistence.lua # Save/load prefs
│ ├── utils/
│ │ ├── dateUtils.lua # Date parsing
│ │ └── timeUtils.lua # Timezone validation
│ └── input/
│ └── inputHandlers.lua # Input handling
└── pdxinfo # Metadata
YYYYMMDDTHHMMSS → epoch)Would you like me to proceed with implementing this plan?
The user wants me to implement the FOSDEM Playdate Schedule Reader application based on the plan. Let me start by creating a todo list to track the implementation, then begin creating the files.
I'll need to: 1. Create the directory structure 2. Create pdxinfo 3. Create config.lua 4. Create utility files (dateUtils.lua, timeUtils.lua) 5. Create data layer files (icsParser.lua, eventStore.lua, networkSync.lua, persistence.lua) 6. Create UI files (warningScreen.lua, scheduleView.lua, qrOverlay.lua, devroomsFilter.lua) 7. Create state.lua 8. Create inputHandlers.lua 9. Create main.lua
Let me start implementing these files.
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
Let me start creating the directory structure and the files. I'll create multiple files in parallel where possible.
mkdir -p /home/nemo/projects/personal/fosdem-playdate/source/{ui,data,utils,input}name=FOSDEM Schedule author=FOSDEM description=Browse the FOSDEM 2026 conference schedule on your Playdate bundleID=org.fosdem.schedule version=1.0.0 buildNumber=1 imagePath= launchSoundPath= contentWarning= contentWarning2=
-- Configuration constants for FOSDEM Schedule app
Config = {
-- UI dimensions
HEADER_HEIGHT = 30,
SUB_HEADER_HEIGHT = 20,
CELL_HEIGHT = 24,
SCREEN_WIDTH = 400,
SCREEN_HEIGHT = 240,
-- Timezone
CET_OFFSET_SECONDS = 3600, -- 1 hour in seconds for CET
-- File paths
ICS_FILE_PATH = "schedule.ics",
ICS_URL_HOST = "fosdem.org",
ICS_URL_PATH = "/2026/schedule/ical",
-- Special categories
MAIN_TRACK = "Main Track",
-- Crank sensitivity
CRANK_THRESHOLD = 30, -- degrees per category switch
}
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/config.lua
-- Date utilities for parsing ICS date format
-- Parse ICS date format: YYYYMMDDTHHMMSS (e.g., 20260201T090000)
-- Returns a table compatible with playdate.epochFromTime()
function parseICSDate(dateStr)
if not dateStr or #dateStr < 15 then
return nil
end
local year = tonumber(dateStr:sub(1, 4))
local month = tonumber(dateStr:sub(5, 6))
local day = tonumber(dateStr:sub(7, 8))
-- Skip the 'T' at position 9
local hour = tonumber(dateStr:sub(10, 11))
local minute = tonumber(dateStr:sub(12, 13))
local second = tonumber(dateStr:sub(14, 15))
if not (year and month and day and hour and minute and second) then
return nil
end
return {
year = year,
month = month,
day = day,
hour = hour,
minute = minute,
second = second,
millisecond = 0
}
end
-- Format time for display based on user preference
function formatTimeForDisplay(timeTable)
if not timeTable then
return "??:??"
end
if playdate.shouldDisplay24HourTime() then
return string.format("%02d:%02d", timeTable.hour, timeTable.minute)
else
local hour = timeTable.hour
local ampm = "am"
if hour >= 12 then
ampm = "pm"
if hour > 12 then
hour = hour - 12
end
end
if hour == 0 then
hour = 12
end
return string.format("%d:%02d %s", hour, timeTable.minute, ampm)
end
end
-- Trim whitespace from string
function trimWhitespace(str)
if not str then
return ""
end
return str:match("^%s*(.-)%s*$")
end
-- Time utilities for timezone validation and epoch conversion
-- Calculate offset between local and GMT time
-- Returns offset in seconds
function getTimezoneOffset()
local localTime = playdate.getTime()
local gmtTime = playdate.getGMTTime()
local localEpoch = playdate.epochFromTime(localTime)
local gmtEpoch = playdate.epochFromGMTTime(gmtTime)
return localEpoch - gmtEpoch
end
-- Check if device timezone is set to CET (+01:00)
function isValidTimezone()
local offset = getTimezoneOffset()
return offset == Config.CET_OFFSET_SECONDS
end
-- Get current time as epoch seconds (since Jan 1, 2000)
function getCurrentEpoch()
local currentTime = playdate.getTime()
return playdate.epochFromTime(currentTime)
end
-- Convert a time table to epoch seconds
function timeToEpoch(timeTable)
if not timeTable then
return 0
end
return playdate.epochFromTime(timeTable)
end
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/utils/dateUtils.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/utils/timeUtils.lua
-- ICS file parser for FOSDEM schedule
-- Parse a single ICS line and return key, value pair
local function parseICSLine(line)
if not line then
return nil, nil
end
-- Handle lines with parameters (e.g., "DTSTART;TZID=Europe-Brussels:20260201T090000")
local colonPos = line:find(":")
if not colonPos then
return nil, nil
end
local keyPart = line:sub(1, colonPos - 1)
local value = line:sub(colonPos + 1)
-- Extract just the key name (before any semicolon)
local semicolonPos = keyPart:find(";")
local key = semicolonPos and keyPart:sub(1, semicolonPos - 1) or keyPart
return key, value
end
-- Split comma-separated categories
local function splitCategories(categoriesStr)
local categories = {}
for cat in categoriesStr:gmatch("([^,]+)") do
local trimmed = trimWhitespace(cat)
if trimmed and #trimmed > 0 then
table.insert(categories, trimmed)
end
end
return categories
end
-- Parse the ICS file and return a list of event tables
function parseICSFile(filePath)
local events = {}
local file = playdate.file.open(filePath, playdate.file.kFileRead)
if not file then
return nil, "Could not open file: " .. filePath
end
local currentEvent = nil
local line = file:readline()
while line do
line = trimWhitespace(line)
if line == "BEGIN:VEVENT" then
currentEvent = {}
elseif line == "END:VEVENT" then
if currentEvent then
-- Calculate epoch times for sorting/comparison
if currentEvent.DTSTART then
currentEvent.DTSTART_epoch = timeToEpoch(currentEvent.DTSTART)
end
if currentEvent.DTEND then
currentEvent.DTEND_epoch = timeToEpoch(currentEvent.DTEND)
end
-- Ensure categories is always a table
if not currentEvent.CATEGORIES then
currentEvent.CATEGORIES = {}
end
table.insert(events, currentEvent)
end
currentEvent = nil
elseif currentEvent then
local key, value = parseICSLine(line)
if key and value then
if key == "DTSTART" or key == "DTEND" then
currentEvent[key] = parseICSDate(value)
elseif key == "CATEGORIES" then
currentEvent[key] = splitCategories(value)
elseif key == "SUMMARY" or key == "URL" or key == "LOCATION" then
currentEvent[key] = value
end
end
end
line = file:readline()
end
file:close()
return events
end
-- Event store for organizing and accessing parsed events
EventStore = {
allEvents = {},
eventsByCategory = {},
categoryNames = {},
}
function EventStore.init(events)
EventStore.allEvents = events or {}
EventStore.eventsByCategory = {}
EventStore.categoryNames = {}
local categorySet = {}
-- Build category map
for _, event in ipairs(EventStore.allEvents) do
local categories = event.CATEGORIES or {}
for _, category in ipairs(categories) do
if not categorySet[category] then
categorySet[category] = true
table.insert(EventStore.categoryNames, category)
end
if not EventStore.eventsByCategory[category] then
EventStore.eventsByCategory[category] = {}
end
table.insert(EventStore.eventsByCategory[category], event)
end
end
-- Sort category names alphabetically
table.sort(EventStore.categoryNames)
-- Sort events within each category by start time
for _, catEvents in pairs(EventStore.eventsByCategory) do
table.sort(catEvents, function(a, b)
return (a.DTSTART_epoch or 0) < (b.DTSTART_epoch or 0)
end)
end
print("EventStore initialized with " .. #EventStore.allEvents .. " events in " .. #EventStore.categoryNames .. " categories")
end
function EventStore.getEventsForCategory(category)
return EventStore.eventsByCategory[category] or {}
end
function EventStore.getCategoryIndex(categoryName)
for i, name in ipairs(EventStore.categoryNames) do
if name == categoryName then
return i
end
end
return 1
end
function EventStore.getCategoryByIndex(index)
return EventStore.categoryNames[index]
end
function EventStore.getCategoryCount()
return #EventStore.categoryNames
end
-- Find the first event in a category where DTEND > current time
function EventStore.findFirstActiveEventIndex(category)
local events = EventStore.getEventsForCategory(category)
local currentEpoch = getCurrentEpoch()
for i, event in ipairs(events) do
if event.DTEND_epoch and event.DTEND_epoch > currentEpoch then
return i
end
end
return 1 -- Default to first event if all have ended
end
-- Network synchronization for downloading ICS schedule
NetworkSync = {
isDownloading = false,
downloadCallback = nil,
http = nil,
receivedData = "",
}
function NetworkSync.downloadSchedule(callback)
if NetworkSync.isDownloading then
callback(false, "Download already in progress")
return
end
NetworkSync.isDownloading = true
NetworkSync.downloadCallback = callback
NetworkSync.receivedData = ""
NetworkSync.http = playdate.network.http.new(
Config.ICS_URL_HOST,
443,
true, -- useSSL
"Download FOSDEM schedule for offline viewing"
)
if not NetworkSync.http then
NetworkSync.isDownloading = false
callback(false, "Could not create HTTP connection")
return
end
NetworkSync.http:setRequestCompleteCallback(function()
NetworkSync.onRequestComplete()
end)
NetworkSync.http:setRequestCallback(function()
NetworkSync.onDataAvailable()
end)
local success, err = NetworkSync.http:get(Config.ICS_URL_PATH)
if not success then
NetworkSync.isDownloading = false
callback(false, err or "Failed to start download")
end
end
function NetworkSync.onDataAvailable()
if NetworkSync.http then
local data = NetworkSync.http:read(65536) -- Read up to 64KB at a time
if data then
NetworkSync.receivedData = NetworkSync.receivedData .. data
end
end
end
function NetworkSync.onRequestComplete()
local callback = NetworkSync.downloadCallback
NetworkSync.isDownloading = false
NetworkSync.downloadCallback = nil
if not NetworkSync.http then
if callback then callback(false, "No HTTP connection") end
return
end
local status = NetworkSync.http:getResponseStatus()
if status == 200 then
-- Read any remaining data
local remaining = NetworkSync.http:read(65536)
while remaining and #remaining > 0 do
NetworkSync.receivedData = NetworkSync.receivedData .. remaining
remaining = NetworkSync.http:read(65536)
end
-- Write to file
local file = playdate.file.open(Config.ICS_FILE_PATH, playdate.file.kFileWrite)
if file then
local written = file:write(NetworkSync.receivedData)
file:close()
if written and written > 0 then
print("Schedule downloaded: " .. written .. " bytes")
if callback then callback(true) end
else
if callback then callback(false, "Could not write file") end
end
else
if callback then callback(false, "Could not create file") end
end
else
if callback then callback(false, "HTTP error: " .. (status or "unknown")) end
end
NetworkSync.http:close()
NetworkSync.http = nil
NetworkSync.receivedData = ""
end
function NetworkSync.scheduleExists()
return playdate.file.exists(Config.ICS_FILE_PATH)
end
-- Persistence for saving and loading user preferences
Persistence = {}
local HIDDEN_CATEGORIES_KEY = "hiddenCategories"
function Persistence.saveHiddenCategories(hiddenCategories)
local data = { hidden = hiddenCategories or {} }
playdate.datastore.write(data, HIDDEN_CATEGORIES_KEY)
print("Saved hidden categories")
end
function Persistence.loadHiddenCategories()
local data = playdate.datastore.read(HIDDEN_CATEGORIES_KEY)
if data and data.hidden then
-- Ensure "Main Track" is never hidden
data.hidden[Config.MAIN_TRACK] = nil
return data.hidden
end
return {}
end
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/data/icsParser.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/data/eventStore.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/data/networkSync.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/data/persistence.lua
-- Warning screen for timezone validation errors
WarningScreen = {}
local gfx <const> = playdate.graphics
function WarningScreen.draw()
gfx.clear(gfx.kColorWhite)
local message = "Timezone must be set to\nCET (+01:00)"
-- Draw a border box
gfx.setColor(gfx.kColorBlack)
gfx.drawRect(50, 80, 300, 80)
-- Draw warning icon (simple exclamation)
gfx.fillTriangle(200, 90, 190, 110, 210, 110)
gfx.setColor(gfx.kColorWhite)
gfx.fillRect(198, 94, 4, 10)
gfx.fillRect(198, 106, 4, 4)
-- Draw message
gfx.setColor(gfx.kColorBlack)
gfx.drawTextAligned(message, 200, 120, kTextAlignment.center)
end
-- Main schedule view using gridview
ScheduleView = {
gridview = nil,
}
local gfx <const> = playdate.graphics
function ScheduleView.init()
-- Create gridview with cell width 0 (full width) and appropriate height
ScheduleView.gridview = playdate.ui.gridview.new(0, Config.CELL_HEIGHT)
ScheduleView.gridview:setContentInset(0, 0, 0, 0)
-- Override drawCell
function ScheduleView.gridview:drawCell(section, row, column, selected, x, y, width, height)
local events = ScheduleView.getCurrentEvents()
local event = events[row]
if not event then return end
-- Draw selection highlight
if selected then
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, width, height)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
else
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
-- Format time based on user preference
local timeStr = formatTimeForDisplay(event.DTSTART)
local summary = event.SUMMARY or "Untitled"
local displayText = "[" .. timeStr .. "] " .. summary
-- Draw with truncation if needed
gfx.drawTextInRect(displayText, x + 4, y + 4, width - 8, height - 8, nil, "...", kTextAlignment.left)
-- Reset draw mode
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
end
function ScheduleView.getCurrentEvents()
local category = State.getCurrentCategory()
if category then
return EventStore.getEventsForCategory(category)
end
return {}
end
function ScheduleView.updateRowCount()
local events = ScheduleView.getCurrentEvents()
ScheduleView.gridview:setNumberOfRows(#events)
end
function ScheduleView.scrollToActiveEvent()
local category = State.getCurrentCategory()
if category then
local activeIndex = EventStore.findFirstActiveEventIndex(category)
ScheduleView.gridview:setSelectedRow(activeIndex)
ScheduleView.gridview:scrollToRow(activeIndex)
end
end
function ScheduleView.getSelectedEvent()
local events = ScheduleView.getCurrentEvents()
local row = ScheduleView.gridview:getSelectedRow()
return events[row]
end
function ScheduleView.draw()
-- Draw header (black background, white text)
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(0, 0, Config.SCREEN_WIDTH, Config.HEADER_HEIGHT)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
local category = State.getCurrentCategory() or "No Category"
local headerText = "< " .. category .. " >"
gfx.drawTextAligned(headerText, 200, 8, kTextAlignment.center)
-- Draw sub-header (location of selected event)
gfx.setImageDrawMode(gfx.kDrawModeCopy)
gfx.setColor(gfx.kColorWhite)
gfx.fillRect(0, Config.HEADER_HEIGHT, Config.SCREEN_WIDTH, Config.SUB_HEADER_HEIGHT)
gfx.setColor(gfx.kColorBlack)
local selectedEvent = ScheduleView.getSelectedEvent()
if selectedEvent then
local location = selectedEvent.LOCATION or "Unknown location"
gfx.drawTextInRect(
location,
4,
Config.HEADER_HEIGHT + 2,
Config.SCREEN_WIDTH - 8,
Config.SUB_HEADER_HEIGHT - 4,
nil,
"...",
kTextAlignment.left
)
end
-- Draw separator line
gfx.drawLine(0, Config.HEADER_HEIGHT + Config.SUB_HEADER_HEIGHT - 1, Config.SCREEN_WIDTH, Config.HEADER_HEIGHT + Config.SUB_HEADER_HEIGHT - 1)
-- Draw gridview
local gridY = Config.HEADER_HEIGHT + Config.SUB_HEADER_HEIGHT
local gridHeight = Config.SCREEN_HEIGHT - gridY
ScheduleView.gridview:drawInRect(0, gridY, Config.SCREEN_WIDTH, gridHeight)
end
-- QR code overlay for displaying event URLs
QROverlay = {
visible = false,
qrImage = nil,
qrTimer = nil,
}
local gfx <const> = playdate.graphics
function QROverlay.show(url)
if not url or #url == 0 then
print("No URL to display")
return
end
QROverlay.visible = true
QROverlay.qrImage = nil
-- Generate QR code asynchronously
QROverlay.qrTimer = gfx.generateQRCode(url, 150, function(image, errorMessage)
if image then
QROverlay.qrImage = image
else
print("QR generation error: " .. (errorMessage or "unknown"))
end
end)
end
function QROverlay.hide()
QROverlay.visible = false
QROverlay.qrImage = nil
if QROverlay.qrTimer then
QROverlay.qrTimer:remove()
QROverlay.qrTimer = nil
end
end
function QROverlay.isVisible()
return QROverlay.visible
end
function QROverlay.draw()
if not QROverlay.visible then return end
-- Draw semi-transparent overlay effect by drawing white background
gfx.setColor(gfx.kColorWhite)
gfx.fillRect(40, 20, 320, 200)
-- Draw border
gfx.setColor(gfx.kColorBlack)
gfx.setLineWidth(2)
gfx.drawRect(40, 20, 320, 200)
gfx.setLineWidth(1)
if QROverlay.qrImage then
-- Draw QR code centered
QROverlay.qrImage:drawCentered(200, 110)
else
gfx.drawTextAligned("Generating QR code...", 200, 110, kTextAlignment.center)
end
-- Draw dismiss instruction
gfx.drawTextAligned("Press B to close", 200, 200, kTextAlignment.center)
end
-- Devrooms filter view for toggling category visibility
DevroomsFilter = {
visible = false,
gridview = nil,
}
local gfx <const> = playdate.graphics
function DevroomsFilter.init()
DevroomsFilter.gridview = playdate.ui.gridview.new(0, 24)
DevroomsFilter.gridview:setContentInset(0, 0, 0, 0)
function DevroomsFilter.gridview:drawCell(section, row, column, selected, x, y, width, height)
local category = EventStore.getCategoryByIndex(row)
if not category then return end
local isHidden = State.hiddenCategories[category] == true
local isMainTrack = category == Config.MAIN_TRACK
if selected then
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(x, y, width, height)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
else
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
local checkbox
if isMainTrack then
checkbox = "[*]" -- Always visible, locked
elseif isHidden then
checkbox = "[ ]"
else
checkbox = "[x]"
end
local displayText = checkbox .. " " .. category
gfx.drawTextInRect(displayText, x + 4, y + 4, width - 8, height - 8, nil, "...", kTextAlignment.left)
gfx.setImageDrawMode(gfx.kDrawModeCopy)
end
end
function DevroomsFilter.show()
DevroomsFilter.visible = true
DevroomsFilter.gridview:setNumberOfRows(EventStore.getCategoryCount())
DevroomsFilter.gridview:setSelectedRow(1)
end
function DevroomsFilter.hide()
DevroomsFilter.visible = false
Persistence.saveHiddenCategories(State.hiddenCategories)
end
function DevroomsFilter.isVisible()
return DevroomsFilter.visible
end
function DevroomsFilter.toggleSelectedCategory()
local row = DevroomsFilter.gridview:getSelectedRow()
local category = EventStore.getCategoryByIndex(row)
if not category then return end
-- Prevent hiding Main Track
if category == Config.MAIN_TRACK then
return
end
if State.hiddenCategories[category] then
State.hiddenCategories[category] = nil
else
State.hiddenCategories[category] = true
end
end
function DevroomsFilter.draw()
if not DevroomsFilter.visible then return end
gfx.clear(gfx.kColorWhite)
-- Header
gfx.setColor(gfx.kColorBlack)
gfx.fillRect(0, 0, Config.SCREEN_WIDTH, Config.HEADER_HEIGHT)
gfx.setImageDrawMode(gfx.kDrawModeFillWhite)
gfx.drawTextAligned("Devrooms Filter", 200, 8, kTextAlignment.center)
-- Instructions
gfx.setImageDrawMode(gfx.kDrawModeCopy)
gfx.drawTextAligned("A: Toggle B: Back", 200, Config.SCREEN_HEIGHT - 16, kTextAlignment.center)
-- Gridview
local gridY = Config.HEADER_HEIGHT
local gridHeight = Config.SCREEN_HEIGHT - gridY - 20
DevroomsFilter.gridview:drawInRect(0, gridY, Config.SCREEN_WIDTH, gridHeight)
end
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/ui/warningScreen.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/ui/scheduleView.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/ui/qrOverlay.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/ui/devroomsFilter.lua
UI components done. Now let me create the state management, input handlers, and main.lua.
-- Application state management
State = {
-- Application states
LOADING = "loading",
TIMEZONE_ERROR = "timezone_error",
SCHEDULE_VIEW = "schedule_view",
QR_VIEW = "qr_view",
DEVROOMS_VIEW = "devrooms_view",
-- Current state
currentState = "loading",
currentCategoryIndex = 1,
visibleCategoryIndex = 1,
hiddenCategories = {},
}
function State.init()
State.currentState = State.LOADING
State.currentCategoryIndex = 1
State.visibleCategoryIndex = 1
State.hiddenCategories = Persistence.loadHiddenCategories()
end
function State.getCurrentCategory()
return EventStore.getCategoryByIndex(State.currentCategoryIndex)
end
-- Get list of visible (non-hidden) categories
function State.getVisibleCategories()
local visible = {}
for i = 1, EventStore.getCategoryCount() do
local category = EventStore.getCategoryByIndex(i)
if category and not State.hiddenCategories[category] then
table.insert(visible, { index = i, name = category })
end
end
return visible
end
function State.getVisibleCategoryCount()
local count = 0
for i = 1, EventStore.getCategoryCount() do
local category = EventStore.getCategoryByIndex(i)
if category and not State.hiddenCategories[category] then
count = count + 1
end
end
return count
end
-- Find the visible index for the current category
function State.findVisibleIndex()
local visibleCategories = State.getVisibleCategories()
local currentCategory = State.getCurrentCategory()
for i, cat in ipairs(visibleCategories) do
if cat.name == currentCategory then
return i
end
end
return 1
end
-- Set category by visible index (skips hidden categories)
function State.setVisibleCategory(visibleIndex)
local visibleCategories = State.getVisibleCategories()
if #visibleCategories == 0 then
return
end
-- Wrap around
if visibleIndex < 1 then
visibleIndex = #visibleCategories
elseif visibleIndex > #visibleCategories then
visibleIndex = 1
end
State.visibleCategoryIndex = visibleIndex
State.currentCategoryIndex = visibleCategories[visibleIndex].index
ScheduleView.updateRowCount()
ScheduleView.scrollToActiveEvent()
end
function State.nextCategory()
local currentVisibleIndex = State.findVisibleIndex()
State.setVisibleCategory(currentVisibleIndex + 1)
end
function State.previousCategory()
local currentVisibleIndex = State.findVisibleIndex()
State.setVisibleCategory(currentVisibleIndex - 1)
end
-- Set to Main Track category
function State.setToMainTrack()
local mainTrackIndex = EventStore.getCategoryIndex(Config.MAIN_TRACK)
if mainTrackIndex then
State.currentCategoryIndex = mainTrackIndex
State.visibleCategoryIndex = State.findVisibleIndex()
ScheduleView.updateRowCount()
ScheduleView.scrollToActiveEvent()
end
end
-- Ensure current category is still visible after filter changes
function State.ensureValidCategory()
local currentCategory = State.getCurrentCategory()
if currentCategory and State.hiddenCategories[currentCategory] then
-- Current category was hidden, switch to first visible
State.setVisibleCategory(1)
else
-- Update visible index
State.visibleCategoryIndex = State.findVisibleIndex()
end
end
-- Input handlers for different application states
InputHandlers = {
crankAccumulator = 0,
}
-- Handlers for main schedule view
InputHandlers.scheduleHandlers = {
upButtonDown = function()
ScheduleView.gridview:selectPreviousRow(false)
end,
downButtonDown = function()
ScheduleView.gridview:selectNextRow(false)
end,
leftButtonDown = function()
State.previousCategory()
end,
rightButtonDown = function()
State.nextCategory()
end,
AButtonDown = function()
local event = ScheduleView.getSelectedEvent()
if event and event.URL and #event.URL > 0 then
State.currentState = State.QR_VIEW
QROverlay.show(event.URL)
playdate.inputHandlers.push(InputHandlers.qrHandlers)
end
end,
cranked = function(change, acceleratedChange)
InputHandlers.crankAccumulator = InputHandlers.crankAccumulator + change
if InputHandlers.crankAccumulator > Config.CRANK_THRESHOLD then
State.nextCategory()
InputHandlers.crankAccumulator = 0
elseif InputHandlers.crankAccumulator < -Config.CRANK_THRESHOLD then
State.previousCategory()
InputHandlers.crankAccumulator = 0
end
end,
}
-- Handlers for QR code overlay
InputHandlers.qrHandlers = {
BButtonDown = function()
QROverlay.hide()
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.pop()
end,
-- Block other inputs while QR is visible
AButtonDown = function() end,
upButtonDown = function() end,
downButtonDown = function() end,
leftButtonDown = function() end,
rightButtonDown = function() end,
cranked = function() end,
}
-- Handlers for devrooms filter view
InputHandlers.devroomsHandlers = {
upButtonDown = function()
DevroomsFilter.gridview:selectPreviousRow(false)
end,
downButtonDown = function()
DevroomsFilter.gridview:selectNextRow(false)
end,
AButtonDown = function()
DevroomsFilter.toggleSelectedCategory()
end,
BButtonDown = function()
DevroomsFilter.hide()
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.pop()
-- Ensure current category is still visible
State.ensureValidCategory()
end,
cranked = function(change, acceleratedChange)
-- Use crank for scrolling in filter view
if change > 5 then
DevroomsFilter.gridview:selectNextRow(false)
elseif change < -5 then
DevroomsFilter.gridview:selectPreviousRow(false)
end
end,
}
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/state.lua
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/input/inputHandlers.lua
-- FOSDEM 2026 Playdate Schedule Reader
-- Main entry point
-- Import CoreLibs
import "CoreLibs/object"
import "CoreLibs/graphics"
import "CoreLibs/ui"
import "CoreLibs/timer"
import "CoreLibs/nineslice"
import "CoreLibs/qrcode"
-- Import application modules
import "config"
import "utils/dateUtils"
import "utils/timeUtils"
import "data/persistence"
import "data/eventStore"
import "data/icsParser"
import "data/networkSync"
import "state"
import "ui/warningScreen"
import "ui/scheduleView"
import "ui/qrOverlay"
import "ui/devroomsFilter"
import "input/inputHandlers"
-- Local constants
local gfx <const> = playdate.graphics
-- Loading state message
local loadingMessage = "Loading..."
-- Setup System Menu
local function setupSystemMenu()
local menu = playdate.getSystemMenu()
menu:addMenuItem("Refresh Schedule", function()
State.currentState = State.LOADING
loadingMessage = "Downloading..."
NetworkSync.downloadSchedule(function(success, error)
if success then
loadSchedule()
else
loadingMessage = "Download failed:\n" .. (error or "Unknown error")
-- Stay in loading state to show error
end
end)
end)
menu:addMenuItem("Devrooms", function()
if State.currentState == State.SCHEDULE_VIEW then
State.currentState = State.DEVROOMS_VIEW
DevroomsFilter.show()
playdate.inputHandlers.push(InputHandlers.devroomsHandlers)
end
end)
end
-- Load and parse the schedule
function loadSchedule()
loadingMessage = "Parsing schedule..."
local events, error = parseICSFile(Config.ICS_FILE_PATH)
if not events then
loadingMessage = "Parse error:\n" .. (error or "Unknown error")
print("Failed to parse ICS file: " .. (error or "unknown"))
return
end
if #events == 0 then
loadingMessage = "No events found in schedule"
return
end
-- Initialize event store
EventStore.init(events)
-- Initialize state
State.init()
-- Initialize UI components
ScheduleView.init()
DevroomsFilter.init()
-- Set initial category to Main Track
State.setToMainTrack()
-- Switch to schedule view
State.currentState = State.SCHEDULE_VIEW
playdate.inputHandlers.push(InputHandlers.scheduleHandlers)
print("Schedule loaded successfully!")
end
-- Application setup
local function setup()
-- Validate timezone
if not isValidTimezone() then
State.currentState = State.TIMEZONE_ERROR
print("Invalid timezone detected")
return
end
-- Setup system menu
setupSystemMenu()
-- Load or download schedule
if NetworkSync.scheduleExists() then
loadingMessage = "Loading schedule..."
-- Use a timer to allow the loading screen to draw first
playdate.timer.performAfterDelay(100, function()
loadSchedule()
end)
else
loadingMessage = "Downloading schedule..."
NetworkSync.downloadSchedule(function(success, error)
if success then
loadSchedule()
else
loadingMessage = "Download failed:\n" .. (error or "Unknown error")
print("Failed to download schedule: " .. (error or "unknown"))
end
end)
end
end
-- Draw loading screen
local function drawLoadingScreen()
gfx.clear(gfx.kColorWhite)
-- Draw title
gfx.drawTextAligned("*FOSDEM Schedule*", 200, 80, kTextAlignment.center)
-- Draw loading message
gfx.drawTextAligned(loadingMessage, 200, 120, kTextAlignment.center)
-- Draw simple spinner animation
local frame = math.floor(playdate.getCurrentTimeMilliseconds() / 200) % 4
local spinnerChars = { "|", "/", "-", "\\" }
gfx.drawTextAligned(spinnerChars[frame + 1], 200, 150, kTextAlignment.center)
end
-- Main update loop
function playdate.update()
-- Handle different states
if State.currentState == State.TIMEZONE_ERROR then
WarningScreen.draw()
elseif State.currentState == State.LOADING then
drawLoadingScreen()
elseif State.currentState == State.SCHEDULE_VIEW then
gfx.clear(gfx.kColorWhite)
ScheduleView.draw()
elseif State.currentState == State.QR_VIEW then
gfx.clear(gfx.kColorWhite)
ScheduleView.draw() -- Draw schedule in background
QROverlay.draw() -- Draw overlay on top
elseif State.currentState == State.DEVROOMS_VIEW then
DevroomsFilter.draw()
end
-- Required for gridview animations, QR code generation, and timers
playdate.timer.updateTimers()
end
-- Lifecycle callbacks
function playdate.gameWillTerminate()
Persistence.saveHiddenCategories(State.hiddenCategories)
end
function playdate.deviceWillSleep()
Persistence.saveHiddenCategories(State.hiddenCategories)
end
function playdate.deviceWillLock()
Persistence.saveHiddenCategories(State.hiddenCategories)
end
-- Start the application
setup()
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
File created successfully at: /home/nemo/projects/personal/fosdem-playdate/source/main.lua
The implementation is complete. Let me update the todo list and show the user the final structure.
find /home/nemo/projects/personal/fosdem-playdate/source -type f | sort
Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
/home/nemo/projects/personal/fosdem-playdate/source/config.lua /home/nemo/projects/personal/fosdem-playdate/source/data/eventStore.lua /home/nemo/projects/personal/fosdem-playdate/source/data/icsParser.lua /home/nemo/projects/personal/fosdem-playdate/source/data/networkSync.lua /home/nemo/projects/personal/fosdem-playdate/source/data/persistence.lua /home/nemo/projects/personal/fosdem-playdate/source/input/inputHandlers.lua /home/nemo/projects/personal/fosdem-playdate/source/main.lua /home/nemo/projects/personal/fosdem-playdate/source/pdxinfo /home/nemo/projects/personal/fosdem-playdate/source/state.lua /home/nemo/projects/personal/fosdem-playdate/source/ui/devroomsFilter.lua /home/nemo/projects/personal/fosdem-playdate/source/ui/qrOverlay.lua /home/nemo/projects/personal/fosdem-playdate/source/ui/scheduleView.lua /home/nemo/projects/personal/fosdem-playdate/source/ui/warningScreen.lua /home/nemo/projects/personal/fosdem-playdate/source/utils/dateUtils.lua /home/nemo/projects/personal/fosdem-playdate/source/utils/timeUtils.lua
All files have been created. Let me also copy the schedule.ics into the source directory so it can be bundled with the app for testing, or at least move it to where the app can find it during development.
Actually, looking at the spec again, the schedule.ics should be in the Data folder (user data), not bundled with the app. But for testing purposes, we could either: 1. Copy it to the source folder so it's available 2. Keep it separate and let the app download it
The spec says it should be in the Data Disk and downloaded if missing. For development/testing, the user would need to either have the schedule.ics in their Playdate Data folder or let the app download it.
Let me provide a summary of what was created.
Implementation complete! Here's the final project structure:
source/
├── pdxinfo # App metadata
├── main.lua # Entry point & update loop
├── config.lua # Constants
├── state.lua # State management
├── data/
│ ├── icsParser.lua # ICS file parsing
│ ├── eventStore.lua # Event organization
│ ├── networkSync.lua # HTTP download
│ └── persistence.lua # Save/load preferences
├── ui/
│ ├── scheduleView.lua # Main gridview
│ ├── qrOverlay.lua # QR code display
│ ├── devroomsFilter.lua # Category filter
│ └── warningScreen.lua # Timezone warning
├── utils/
│ ├── dateUtils.lua # Date parsing (YYYYMMDDTHHMMSS)
│ └── timeUtils.lua # Timezone validation
└── input/
└── inputHandlers.lua # D-pad, crank, buttons
[HH:MM] Summary formatUse the Playdate SDK compiler:
pdc source fosdem-schedule.pdx
Then run in the simulator or sideload to device. The app will download the schedule on first run (or you can place schedule.ics in the Data folder manually).
Are you sure this is correct -- Calculate offset between local and GMT time -- Returns offset in seconds function getTimezoneOffset() local localTime = playdate.getTime() local gmtTime = playdate.getGMTTime()
local localEpoch = playdate.epochFromTime(localTime)
local gmtEpoch = playdate.epochFromGMTTime(gmtTime)
return localEpoch - gmtEpoch
end
The user is questioning my timezone offset calculation. Let me think about this more carefully.
Looking at the Playdate API:
playdate.getTime() - Returns a table with values for the local timeplaydate.getGMTTime() - Returns a table in the same format as playdate.getTime(), but in GMT rather than local timeplaydate.epochFromTime(time) - Returns the number of seconds between midnight Jan 1 2000 UTC and time, specified in local timeplaydate.epochFromGMTTime(time) - Returns the number of seconds between midnight Jan 1 2000 UTC and time, specified in GMT timeSo if I call:
- playdate.getTime() → returns local time table (e.g., 14:00 in CET)
- playdate.getGMTTime() → returns GMT time table (e.g., 13:00 in GMT)
Then:
- playdate.epochFromTime(localTime) - This interprets the localTime table as local time and converts it to epoch. So it takes {hour=14, ...} and says "14:00 local time is X seconds since epoch"
- playdate.epochFromGMTTime(gmtTime) - This interprets the gmtTime table as GMT time and converts it to epoch. So it takes {hour=13, ...} and says "13:00 GMT is Y seconds since epoch"
Wait, but both should return the SAME epoch value because they represent the same moment in time! The local time 14:00 CET and GMT time 13:00 are the same instant.
So localEpoch - gmtEpoch would be 0, not 3600.
The problem is that I'm using the wrong approach. Let me think about what the SPEC actually says:
From SPEC.md:
pd.update (or init), calculate offset: (playdate.getTime() - playdate.getGMTTime()).You can't directly subtract time tables, so the spec is ambiguous. What it likely means is comparing the hour values—if local time shows 14:00 and GMT shows 13:00, the offset would be (14-13)*3600 plus any minute/second differences.
However, this breaks around midnight. A more robust approach: take the local time table and convert it both ways—once with epochFromTime() treating it as local time, and once with epochFromGMTTime() treating the same table as GMT. The difference between these epochs gives the timezone offset, since the same hour value interpreted in different time zones produces different epoch values. } as local time, which is 13:00 UTC, so it returns that epoch. But epochFromGMTTime(localTime) interprets the same {hour=14,...} as GMT time, giving 14:00 UTC. The difference between these is exactly the offset I need.
Testing this with the concrete example: localTime = {hour=14,...} represents 14:00 CET locally. Interpreting it as GMT would be 14:00 UTC. Since 14:00 CET is actually 13:00 UTC, the offset is -3600 seconds (one hour behind). This approach correctly captures the timezone difference by using the same time values but interpreting them in different ways. :00 UTC" → epoch C
- epochFromGMTTime(gmtTime) - "13:00 GMT" = "13:00 UTC" → epoch D
- D - C = 3600 seconds
So epochFromGMTTime(gmtTime) - epochFromTime(gmtTime) gives 3600 for CET. The same logic applies with localTime: epochFromGMTTime(localTime) - epochFromTime(localTime) also yields 3600. The implementation should subtract the local epoch from the GMT epoch to get the correct offset.
function getTimezoneOffset()
local localTime = playdate.getTime()
local asLocalEpoch = playdate.epochFromTime(localTime)
local asGMTEpoch = playdate.epochFromGMTTime(localTime)
return asGMTEpoch - asLocalEpoch
end
By passing the same time table to both functions, they interpret it differently—one as local time, one as GMT—and the difference reveals the timezone offset. Testing with CET at 14:00 local: epochFromTime treats it as 13:00 UTC while epochFromGMTTime treats it as 14:00 UTC, yielding the correct 3600-second difference. The original approach failed because it didn't account for how each function interprets the input.
You're right to question that - it's wrong! Let me think through it:
playdate.getTime() → local time table (e.g., {hour=14, ...} in CET)playdate.getGMTTime() → GMT time table (e.g., {hour=13, ...})epochFromTime(localTime) → interprets the table as local time → epoch for 13:00 UTCepochFromGMTTime(gmtTime) → interprets the table as GMT → epoch for 13:00 UTCBoth return the same epoch because they represent the same moment in time! The subtraction would always be ~0.
The correct approach is to use the same time table interpreted two different ways:
-- Calculate offset between local and GMT time
-- Returns offset in seconds
function getTimezoneOffset()
local localTime = playdate.getTime()
local gmtTime = playdate.getGMTTime()
local localEpoch = playdate.epochFromTime(localTime)
local gmtEpoch = playdate.epochFromGMTTime(gmtTime)
return localEpoch - gmtEpoch
end-- Calculate offset between local and GMT time
-- Returns offset in seconds (positive for east of GMT, e.g., +3600 for CET)
function getTimezoneOffset()
local timeTable = playdate.getTime()
-- Interpret the SAME time table two different ways:
-- epochFromTime treats it as local time
-- epochFromGMTTime treats it as GMT time
local asLocalEpoch = playdate.epochFromTime(timeTable)
local asGMTEpoch = playdate.epochFromGMTTime(timeTable)
-- Example for CET (+1) when local time is 14:00:
-- asLocalEpoch: "14:00 local" = 13:00 UTC = epoch X
-- asGMTEpoch: "14:00 GMT" = 14:00 UTC = epoch X + 3600
-- Difference: 3600 seconds
return asGMTEpoch - asLocalEpoch
endThe file /home/nemo/projects/personal/fosdem-playdate/source/utils/timeUtils.lua has been updated successfully.
Good, I fixed the timezone offset calculation. The key insight is:
- Use the same time table
- Interpret it as local time with epochFromTime()
- Interpret it as GMT with epochFromGMTTime()
- The difference gives the offset
For CET (+1 hour):
- If local time is 14:00, that's 13:00 UTC
- epochFromTime({hour=14}) = epoch for 13:00 UTC
- epochFromGMTTime({hour=14}) = epoch for 14:00 UTC
- Difference = +3600 seconds ✓
Fixed. The key insight:
| Function | Input {hour=14} |
Interpretation | UTC equivalent |
|---|---|---|---|
epochFromTime() |
14:00 local (CET) | → | 13:00 UTC |
epochFromGMTTime() |
14:00 GMT | → | 14:00 UTC |
Difference: 14:00 UTC - 13:00 UTC = 3600 seconds ✓
The user wants quirky, German-inspired names for the FOSDEM Playdate Schedule app. FOSDEM is held in Brussels, Belgium, but they want German flair. Let me think of some fun names that play on:
German words to consider: - Fahrplan = timetable/schedule (literally "driving plan", used for trains) - Stundenplan = timetable (school schedule) - Zeitplan = schedule/timeline - Terminkalender = appointment calendar - Kurbel = crank (relevant to Playdate!) - Spielerei = playful thing, toy - Taschenfahrplan = pocket timetable - Wunderbar = wonderful - Gemütlich = cozy - Schnell = fast - Klein = small
Fun ideas: 1. Taschenfahrplan - "Pocket Timetable" - very German, very practical 2. Kurbelkalender - "Crank Calendar" - combines Playdate's crank with calendar 3. FOSDEMfahrplan - combines FOSDEM with Fahrplan 4. Der kleine Zeitplan - "The little schedule" - cute 5. Terminwunder - "Appointment miracle" 6. Zeitkurbel - "Time crank" 7. Planung mit Kurbel - "Planning with crank" 8. Das Fahrplänchen - diminutive of Fahrplan, "the little timetable" 9. Kurbelzeit - "Crank time" 10. Plänchen - little plan (cute diminutive)
Let me pick 5 good ones with explanations:
Here are 5 quirky German-inspired names:
Kurbelfahrplan - "Crank Timetable" - marries Playdate's signature crank with the German word for train schedules
Das Plänchen - "The Little Plan" - cute diminutive, sounds endearing and pocketable
Zeitkurbel - "Time Crank" - evokes cranking through time/schedule, very Playdate
Taschenfahrplan - "Pocket Timetable" - what Germans call those fold-out train schedules; fits the Playdate perfectly
Wunderkurbel - "Wonder Crank" - playful, slightly absurd, memorable
My favorite is Kurbelfahrplan - it captures both the Playdate identity (crank) and the German precision of a proper Fahrplan (the sacred train timetable that Germans take very seriously).