ADD - build persistent calendar app

This commit is contained in:
smx.pusha
2026-08-06 15:29:52 +02:00
parent 270d18b347
commit c304f4a9f3
20 changed files with 1002 additions and 5 deletions
+40 -1
View File
@@ -46,7 +46,13 @@ import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
type?: string
data?: MailEventData | MarketplaceEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload
data?:
| CalendarReminderData
| MailEventData
| MarketplaceEventData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
}
type SimPickerPayload = {
@@ -73,6 +79,15 @@ type MarketplaceEventData = {
title?: string
}
type CalendarReminderData = {
device?: PhoneNotificationDevicePayload
eventId?: string
eventTitle?: string
startsAt?: number
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -195,6 +210,30 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
notifications.show(notification)
void marketplace.loadCounts()
} else if (event.data?.type === 'calendar:reminder' && event.data.data) {
const data = event.data.data as CalendarReminderData
const startsAt = Number(data.startsAt) || Date.now()
const notification: PhoneNotificationInput = {
appId: 'calendar',
subtitle: new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
}).format(startsAt),
text:
data.text ??
phone.t('Apps.calendar.reminderNotification', {
title: data.eventTitle ?? '',
}),
title: data.title ?? phone.t('Apps.calendar.name'),
}
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
} else if (event.data?.type === 'contacts:changed') {
void calls.loadContacts()
} else if (event.data?.type === 'calls:changed') {
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Calendar">
<defs>
<linearGradient id="bg" x1="32" y1="20" x2="224" y2="236" gradientUnits="userSpaceOnUse">
<stop stop-color="#ff765f"/>
<stop offset="1" stop-color="#e93147"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#8c1021" flood-opacity=".32"/>
</filter>
</defs>
<rect width="256" height="256" rx="58" fill="url(#bg)"/>
<g filter="url(#shadow)">
<rect x="42" y="50" width="172" height="164" rx="28" fill="#fff"/>
<path d="M42 78c0-15.5 12.5-28 28-28h116c15.5 0 28 12.5 28 28v26H42V78Z" fill="#f8e9eb"/>
<rect x="77" y="36" width="14" height="42" rx="7" fill="#fff"/>
<rect x="165" y="36" width="14" height="42" rx="7" fill="#fff"/>
<g fill="#d5d8df">
<rect x="67" y="124" width="26" height="21" rx="7"/><rect x="104" y="124" width="26" height="21" rx="7"/><rect x="141" y="124" width="26" height="21" rx="7"/>
<rect x="67" y="157" width="26" height="21" rx="7"/><rect x="141" y="157" width="26" height="21" rx="7"/>
</g>
<rect x="101" y="153" width="32" height="29" rx="9" fill="#ff4259"/>
<path d="m109 167 7 7 11-14" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<circle cx="201" cy="198" r="27" fill="#272b36" stroke="#fff" stroke-width="6"/>
<path d="M201 183v16l10 7" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+10 -3
View File
@@ -10,9 +10,11 @@ describe('app registry', () => {
(app) => app.route === null || app.route === `/apps/${app.id}`,
),
).toBe(true)
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
true,
)
expect(
PHONE_APPS.every(
(app) => typeof app.iconImage === 'string' && app.iconImage.length > 0,
),
).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({
dockOrder: 0,
labelKey: 'Apps.phone.name',
@@ -28,6 +30,11 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 20,
labelKey: 'Apps.calendar.name',
route: '/apps/calendar',
})
expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({
dockOrder: null,
gridOrder: 11,
+15
View File
@@ -3,6 +3,7 @@ import {
Bomb,
Blocks,
Camera,
CalendarDays,
Clock3,
Gamepad2,
Grid2X2,
@@ -26,6 +27,7 @@ import appStoreIcon from '@/assets/img/app-icons/apps.webp'
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
import cameraIcon from '@/assets/img/app-icons/camera.webp'
import clockIcon from '@/assets/img/app-icons/clock.webp'
import calendarIcon from '@/assets/img/app-icons/calendar.svg'
import mailIcon from '@/assets/img/app-icons/mail.webp'
import mapIcon from '@/assets/img/app-icons/map.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
@@ -49,6 +51,19 @@ import type {
} from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
),
dockOrder: null,
gridOrder: 20,
icon: markRaw(CalendarDays),
iconClass: 'app-icon--calendar',
iconImage: calendarIcon,
id: 'calendar',
labelKey: 'Apps.calendar.name',
route: '/apps/calendar',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
+61
View File
@@ -0,0 +1,61 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCalendarStore } from '@/stores/calendar'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
describe('calendar store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads a bounded calendar range', async () => {
mockNuiCall.mockResolvedValueOnce({ data: [], success: true })
const calendar = useCalendarStore()
expect(await calendar.load(1_000_000, 2_000_000)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('calendar:list', {
endsAt: 2000,
startsAt: 1000,
})
})
it('sends server timestamps and revisions when updating', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const calendar = useCalendarStore()
await calendar.update(
{
endsAt: 0,
id: 'event-id',
note: '',
remindedAt: null,
reminderMinutes: null,
revision: 4,
startsAt: 0,
title: '',
},
{
endsAt: 2_000_000,
note: 'Bring documents',
reminderMinutes: 60,
startsAt: 1_000_000,
title: 'Meeting',
},
)
expect(mockNuiCall).toHaveBeenCalledWith('calendar:update', {
endsAt: 2000,
id: 'event-id',
note: 'Bring documents',
reminderMinutes: 60,
revision: 4,
startsAt: 1000,
title: 'Meeting',
})
})
})
+63
View File
@@ -0,0 +1,63 @@
import { defineStore } from 'pinia'
import type { CalendarEvent, CalendarEventDraft } from '@/types/calendar'
import { nuiCall } from '@/utils/nui'
function toServerDraft(draft: CalendarEventDraft): Record<string, unknown> {
return {
endsAt: Math.floor(draft.endsAt / 1000),
note: draft.note,
reminderMinutes: draft.reminderMinutes,
startsAt: Math.floor(draft.startsAt / 1000),
title: draft.title,
}
}
export const useCalendarStore = defineStore('calendar', {
state: () => ({
error: '' as string,
events: [] as CalendarEvent[],
loading: false,
}),
actions: {
async create(draft: CalendarEventDraft): Promise<boolean> {
const response = await nuiCall<{ id: string }>(
'calendar:create',
toServerDraft(draft),
)
this.error = response.success ? '' : (response.error ?? 'default')
return response.success
},
async deleteEvent(id: string): Promise<boolean> {
const response = await nuiCall('calendar:delete', { id })
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success) {
this.events = this.events.filter((event) => event.id !== id)
}
return response.success
},
async load(startsAt: number, endsAt: number): Promise<boolean> {
this.loading = true
const response = await nuiCall<CalendarEvent[]>('calendar:list', {
endsAt: Math.floor(endsAt / 1000),
startsAt: Math.floor(startsAt / 1000),
})
this.loading = false
this.error = response.success ? '' : (response.error ?? 'default')
if (response.success) this.events = response.data ?? []
return response.success
},
async update(
event: CalendarEvent,
draft: CalendarEventDraft,
): Promise<boolean> {
const response = await nuiCall('calendar:update', {
...toServerDraft(draft),
id: event.id,
revision: event.revision,
})
this.error = response.success ? '' : (response.error ?? 'default')
return response.success
},
},
})
+41
View File
@@ -522,6 +522,47 @@ const defaultLocales: LocaleTree = {
upload_timeout: 'The media upload timed out.',
},
},
calendar: {
name: 'Calendar',
eyebrow: 'Your time. Clearly planned.',
schedule: 'Schedule',
event: 'Event',
appointment: 'Appointment',
newEvent: 'New event',
editEvent: 'Edit event',
deleteEvent: 'Delete event',
details: 'Event details',
title: 'Title',
titlePlaceholder: 'What is planned?',
date: 'Date',
starts: 'Starts',
ends: 'Ends',
reminder: 'Reminder',
note: 'Note',
notePlaceholder: 'Add details, an address or anything to remember...',
noEvents: 'Nothing planned',
noEventsBody: 'This day is still free. Add an event whenever you are ready.',
signInTitle: 'Your iFruit calendar',
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
reminderNotification: 'Upcoming: {title}',
reminders: {
none: 'No reminder',
atStart: 'At start time',
tenMinutes: '10 minutes before',
thirtyMinutes: '30 minutes before',
oneHour: '1 hour before',
oneDay: '1 day before',
},
errors: {
invalid_event: 'Enter a title and make sure the end is after the start.',
invalid_range: 'This calendar period is invalid.',
conflict: 'This event changed on another phone. Open it again.',
rate_limited: 'Too many changes. Try again shortly.',
not_authenticated: 'Sign in to your iFruit account first.',
request_failed: 'Calendar is temporarily unavailable.',
default: 'The calendar request failed.',
},
},
clock: {
name: 'Clock',
lap: 'Lap',
+1
View File
@@ -5,6 +5,7 @@ export type PhoneAppId =
| 'calculator'
| 'camera'
| 'clock'
| 'calendar'
| 'weather'
| 'mail'
| 'map'
+18
View File
@@ -0,0 +1,18 @@
export type CalendarEvent = {
endsAt: number
id: string
note: string
remindedAt: number | null
reminderMinutes: number | null
revision: number
startsAt: number
title: string
}
export type CalendarEventDraft = {
endsAt: number
note: string
reminderMinutes: number | null
startsAt: number
title: string
}
+1
View File
@@ -61,6 +61,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'local-pages': { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
File diff suppressed because one or more lines are too long
+56
View File
@@ -134,6 +134,7 @@ let mockNotes = [
updatedAt: Date.now() - 3_600_000,
},
]
let calendarEvents = []
const deviceData = {}
let mockMedia = [
{
@@ -430,10 +431,65 @@ app.post('/api/:endpoint', (request, response) => {
linkedAccount = null
mockNotes = []
mockMedia = []
calendarEvents = []
for (const key of Object.keys(deviceData)) delete deviceData[key]
response.json({ success: true })
return
}
if (endpoint.startsWith('calendar:') && !authenticated) {
response.json({ success: false, error: 'not_authenticated' })
return
}
if (endpoint === 'calendar:list') {
const startsAt = Number(request.body.startsAt) * 1000
const endsAt = Number(request.body.endsAt) * 1000
response.json({
success: true,
data: calendarEvents.filter(
(event) => event.endsAt >= startsAt && event.startsAt < endsAt,
),
})
return
}
if (endpoint === 'calendar:create') {
const id = `calendar-${Date.now()}`
calendarEvents.push({
...request.body,
endsAt: Number(request.body.endsAt) * 1000,
id,
remindedAt: null,
revision: 1,
startsAt: Number(request.body.startsAt) * 1000,
})
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'calendar:update') {
const index = calendarEvents.findIndex(
(event) => event.id === request.body.id,
)
if (index < 0 || calendarEvents[index].revision !== request.body.revision) {
response.json({ success: false, error: 'conflict' })
return
}
calendarEvents[index] = {
...calendarEvents[index],
...request.body,
endsAt: Number(request.body.endsAt) * 1000,
remindedAt: null,
revision: calendarEvents[index].revision + 1,
startsAt: Number(request.body.startsAt) * 1000,
}
response.json({ success: true })
return
}
if (endpoint === 'calendar:delete') {
calendarEvents = calendarEvents.filter(
(event) => event.id !== request.body.id,
)
response.json({ success: true })
return
}
if (endpoint === 'pages:list') {
const query = String(request.body.search ?? '').toLowerCase()
let items = pagesPosts
+10
View File
@@ -102,3 +102,13 @@ Config.LocalPages = {
Categories = { "recommendation", "wanted", "service", "event", "place", "community" },
CityMarktSharesPerDay = 1,
}
Config.Calendar = {
TitleMaxLength = 120,
NoteMaxLength = 2000,
MaximumDurationSeconds = 7 * 24 * 60 * 60,
MaximumQuerySeconds = 370 * 24 * 60 * 60,
PastEditSeconds = 365 * 24 * 60 * 60,
FutureSeconds = 5 * 365 * 24 * 60 * 60,
ReminderPollSeconds = 15,
}
+3
View File
@@ -167,6 +167,9 @@ Locales["en"] = {
upload_failed = "The media upload failed.", upload_timeout = "The media upload timed out.",
},
},
calendar = {
name = "Calendar", reminder = "Upcoming: {title}",
},
clock = {
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add alarm", location = "Los Santos",
tabs = { world = "Clock", alarm = "Alarm", stopwatch = "Stopwatch", timer = "Timer" },
+1
View File
@@ -48,6 +48,7 @@ server_scripts {
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/media.lua',
'source/server/calendar.lua',
}
files {
+11
View File
@@ -55,6 +55,10 @@ local server_callbacks = {
"pages:share-citymarkt",
"pages:react",
"pages:delete",
"calendar:list",
"calendar:create",
"calendar:update",
"calendar:delete",
"sim:insert",
"sim:eject",
"contacts:list",
@@ -313,6 +317,13 @@ RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
SendNUIMessage({ type = "marketplace:new-message", data = data })
end)
RegisterNetEvent("sky_phone:calendar:reminder", function(data)
local calendar_locale = get_locale().Nui.Apps.calendar
data.title = calendar_locale.name
data.text = calendar_locale.reminder:gsub("{title}", tostring(data.eventTitle))
SendNUIMessage({ type = "calendar:reminder", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
sim_picker_open = true
SetNuiFocus(true, true)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-BZAEmZnj.js"></script>
<script type="module" crossorigin src="./assets/sky-index-Dj6qNtrv.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DnowIFpD.css">
</head>
<body>
+218
View File
@@ -0,0 +1,218 @@
Bridge.Database.AfterMigration("sky_phone", function()
local reminder_minutes = {
[0] = true,
[10] = true,
[30] = true,
[60] = true,
[1440] = true,
}
local function text_length(value)
return type(value) == "string" and utf8.len(value) or nil
end
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function event_dto(row)
return {
id = row.id,
title = row.title,
note = row.note,
startsAt = (tonumber(row.starts_at_unix) or 0) * 1000,
endsAt = (tonumber(row.ends_at_unix) or 0) * 1000,
reminderMinutes = row.reminder_minutes and tonumber(row.reminder_minutes) or nil,
remindedAt = row.reminded_at_unix and tonumber(row.reminded_at_unix) * 1000 or nil,
revision = tonumber(row.revision) or 1,
}
end
local function list_events(account_id, starts_at, ends_at)
local rows = Bridge.Database.Query([[
SELECT `id`, `title`, `note`, `reminder_minutes`, `revision`,
UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`,
UNIX_TIMESTAMP(`ends_at`) AS `ends_at_unix`,
UNIX_TIMESTAMP(`reminded_at`) AS `reminded_at_unix`
FROM `sky_phone_calendar_events`
WHERE `account_id` = ?
AND `ends_at` >= FROM_UNIXTIME(?)
AND `starts_at` < FROM_UNIXTIME(?)
ORDER BY `starts_at`, `ends_at`, `id`
LIMIT 500
]], { account_id, starts_at, ends_at })
local events = {}
for _, row in ipairs(rows) do
events[#events + 1] = event_dto(row)
end
return events
end
local function valid_range(starts_at, ends_at)
local now = os.time()
return starts_at >= now - Config.Calendar.PastEditSeconds
and starts_at <= now + Config.Calendar.FutureSeconds
and ends_at > starts_at
and ends_at - starts_at <= Config.Calendar.MaximumDurationSeconds
end
local function validate_event(data)
if type(data) ~= "table" then
return nil
end
local title_length = text_length(data.title)
local note_length = text_length(data.note)
local starts_at = math.floor(tonumber(data.startsAt) or 0)
local ends_at = math.floor(tonumber(data.endsAt) or 0)
local reminder = data.reminderMinutes
if not title_length
or title_length < 1
or title_length > Config.Calendar.TitleMaxLength
or not note_length
or note_length > Config.Calendar.NoteMaxLength
or not valid_range(starts_at, ends_at)
or (reminder ~= nil and not reminder_minutes[tonumber(reminder)])
then
return nil
end
return {
title = data.title,
note = data.note,
starts_at = starts_at,
ends_at = ends_at,
reminder_minutes = reminder == nil and nil or tonumber(reminder),
}
end
Bridge.Callbacks.Register("sky_phone:calendar:list", function(source, data)
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local starts_at = math.floor(tonumber(data and data.startsAt) or 0)
local ends_at = math.floor(tonumber(data and data.endsAt) or 0)
if starts_at <= 0 or ends_at <= starts_at or ends_at - starts_at > Config.Calendar.MaximumQuerySeconds then
return { success = false, error = "invalid_range" }
end
return { success = true, data = list_events(account.id, starts_at, ends_at) }
end)
Bridge.Callbacks.Register("sky_phone:calendar:create", function(source, data)
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local event = validate_event(data)
if not event then
return { success = false, error = "invalid_event" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = ids[1] and ids[1].id
if type(id) ~= "string" then
error("[sky_phone] Database did not generate a calendar event id.")
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_calendar_events`
(`id`, `account_id`, `title`, `note`, `starts_at`, `ends_at`, `reminder_minutes`)
VALUES (?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), ?)
]], {
id,
account.id,
event.title,
event.note,
event.starts_at,
event.ends_at,
event.reminder_minutes,
})
return { success = true, data = { id = id } }
end)
Bridge.Callbacks.Register("sky_phone:calendar:update", function(source, data)
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
local event = validate_event(data)
if not event or type(data.id) ~= "string" then
return { success = false, error = "invalid_event" }
end
local revision = math.max(1, math.floor(tonumber(data.revision) or 0))
local result = Bridge.Database.Query([[
UPDATE `sky_phone_calendar_events`
SET `title` = ?, `note` = ?, `starts_at` = FROM_UNIXTIME(?),
`ends_at` = FROM_UNIXTIME(?), `reminder_minutes` = ?,
`reminded_at` = NULL, `revision` = `revision` + 1
WHERE `id` = ? AND `account_id` = ? AND `revision` = ?
]], {
event.title,
event.note,
event.starts_at,
event.ends_at,
event.reminder_minutes,
data.id,
account.id,
revision,
})
if affected_rows(result) ~= 1 then
return { success = false, error = "conflict" }
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:calendar:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then
return error_response
end
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "invalid_event" }
end
Bridge.Database.Query(
"DELETE FROM `sky_phone_calendar_events` WHERE `id` = ? AND `account_id` = ?",
{ data.id, account.id }
)
return { success = true }
end)
CreateThread(function()
while true do
Wait(Config.Calendar.ReminderPollSeconds * 1000)
local due_events = Bridge.Database.Query([[
SELECT `id`, `account_id`, `title`, UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`
FROM `sky_phone_calendar_events`
WHERE `reminder_minutes` IS NOT NULL
AND `reminded_at` IS NULL
AND DATE_SUB(`starts_at`, INTERVAL `reminder_minutes` MINUTE) <= CURRENT_TIMESTAMP
AND `starts_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)
ORDER BY `starts_at`
LIMIT 100
]], {})
for _, event in ipairs(due_events) do
local result = Bridge.Database.Query([[
UPDATE `sky_phone_calendar_events`
SET `reminded_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `reminded_at` IS NULL
]], { event.id })
if affected_rows(result) == 1 then
SkyPhone.NotifyAccountDevices(event.account_id, "sky_phone:calendar:reminder", {
eventId = event.id,
eventTitle = event.title,
startsAt = (tonumber(event.starts_at_unix) or 0) * 1000,
})
end
end
end
end)
end)
+25
View File
@@ -604,6 +604,31 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_calendar_events",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "title", type = "VARCHAR(120) NOT NULL" },
{ name = "note", type = "TEXT NOT NULL" },
{ name = "starts_at", type = "DATETIME NOT NULL" },
{ name = "ends_at", type = "DATETIME NOT NULL" },
{ name = "reminder_minutes", type = "SMALLINT UNSIGNED NULL" },
{ name = "reminded_at", type = "DATETIME NULL" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_calendar_account", columns = "(`account_id`, `starts_at`)" },
{ name = "idx_sky_phone_calendar_reminders", columns = "(`reminded_at`, `starts_at`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
+18
View File
@@ -176,3 +176,21 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(120) NOT NULL,
`note` TEXT NOT NULL,
`starts_at` DATETIME NOT NULL,
`ends_at` DATETIME NOT NULL,
`reminder_minutes` SMALLINT UNSIGNED NULL,
`reminded_at` DATETIME NULL,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_calendar_account` (`account_id`, `starts_at`),
KEY `idx_sky_phone_calendar_reminders` (`reminded_at`, `starts_at`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;