Merge branch 'weather-app' into dev

This commit is contained in:
Type
2026-08-15 00:02:44 +02:00
23 changed files with 2052 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+7
View File
@@ -32,6 +32,13 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'health')).toMatchObject({
category: 'utilities',
gridOrder: 11,
labelKey: 'Apps.health.name',
route: '/apps/health',
})
expect(isPhoneAppId('health')).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
gridOrder: 5,
labelKey: 'Apps.banking.name',
+18
View File
@@ -35,6 +35,7 @@ import {
UsersRound,
Building2,
Newspaper,
HeartPulse,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
@@ -61,6 +62,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import healthIcon from '@/assets/img/app-icons/health.webp'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import billingIcon from '@/assets/img/app-icons/billing.svg'
import garageIcon from '@/assets/img/app-icons/garage.webp'
@@ -87,6 +89,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/HealthApp.vue')),
),
dockOrder: null,
gridOrder: 11,
icon: markRaw(HeartPulse),
iconClass: 'app-icon--health',
iconImage: healthIcon,
id: 'health',
labelKey: 'Apps.health.name',
route: '/apps/health',
},
{
category: 'social',
component: markRaw(
@@ -643,6 +659,7 @@ export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> =
'settings',
'map',
'calendar',
'health',
])
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
@@ -654,6 +671,7 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
'phone',
'messages',
'mail',
'health',
])
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
+5 -2
View File
@@ -35,7 +35,7 @@ describe('app store', () => {
vi.useRealTimers()
})
it('installs only the fourteen standard built-in apps by default', () => {
it('installs the fifteen system apps by default', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
@@ -55,6 +55,7 @@ describe('app store', () => {
'settings',
'map',
'calendar',
'health',
])
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.isInstalled(appId)).toBe(true)
@@ -62,6 +63,7 @@ describe('app store', () => {
expect(apps.isInstalled('banking')).toBe(false)
expect(apps.isInstalled('feather')).toBe(false)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -157,7 +159,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('installs an app after showing a three second loading state', () => {
it('installs a downloadable app after a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -303,6 +305,7 @@ describe('app store', () => {
'phone',
'messages',
'mail',
'health',
])
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
+64
View File
@@ -0,0 +1,64 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useHealthStore } from '@/stores/health'
import type { HealthOverview } from '@/types/health'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const overview: HealthOverview = {
dailyStepGoal: 8000,
days: [],
emergencyNumber: '911',
medicalId: {
allergies: '',
bloodType: '',
conditions: '',
emergencyName: '',
emergencyPhone: '',
emergencyRelation: '',
medication: '',
playerName: 'Alex Morgan',
},
previousWeekSteps: 0,
snapshot: { healthPercent: 100 },
}
describe('health store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('loads the server-authoritative health overview', async () => {
vi.mocked(nuiCall).mockResolvedValue({ data: overview, success: true })
const health = useHealthStore()
await expect(health.load()).resolves.toBe(true)
expect(nuiCall).toHaveBeenCalledWith('health:overview')
expect(health.overview?.dailyStepGoal).toBe(8000)
})
it('persists medical ID edits through NUI', async () => {
const health = useHealthStore()
health.overview = structuredClone(overview)
vi.mocked(nuiCall).mockResolvedValue({
data: { ...overview.medicalId, bloodType: 'O+' },
success: true,
})
await expect(
health.saveMedicalId({
allergies: overview.medicalId.allergies,
bloodType: 'O+',
conditions: overview.medicalId.conditions,
emergencyName: overview.medicalId.emergencyName,
emergencyPhone: overview.medicalId.emergencyPhone,
emergencyRelation: overview.medicalId.emergencyRelation,
medication: overview.medicalId.medication,
}),
).resolves.toBe(true)
expect(health.overview.medicalId.bloodType).toBe('O+')
})
})
+54
View File
@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import type {
HealthMedicalId,
HealthMedicalIdInput,
HealthOverview,
} from '@/types/health'
import { nuiCall } from '@/utils/nui'
export const useHealthStore = defineStore('health', {
state: () => ({
error: '',
isLoading: false,
isSaving: false,
overview: null as HealthOverview | null,
requestGeneration: 0,
}),
actions: {
async load(): Promise<boolean> {
const generation = ++this.requestGeneration
this.isLoading = true
const response = await nuiCall<HealthOverview>('health:overview').finally(
() => {
if (generation === this.requestGeneration) this.isLoading = false
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async saveMedicalId(data: HealthMedicalIdInput): Promise<boolean> {
if (this.isSaving) return false
this.isSaving = true
const response = await nuiCall<HealthMedicalId>(
'health:save-profile',
data,
).finally(() => {
this.isSaving = false
})
if (response.success && response.data && this.overview) {
this.overview.medicalId = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
},
})
+65
View File
@@ -349,6 +349,70 @@ const companiesFallbackLocales = {
},
}
const healthFallbackLocales = {
name: 'Health',
navigation: 'Health navigation',
tabs: { today: 'Today', trends: 'Trends', medicalId: 'Medical ID' },
loading: 'Loading health data...',
errorTitle: 'Health is unavailable',
errorBody: 'Your activity data could not be loaded.',
tryAgain: 'Try Again',
goal: 'of {count}',
steps: 'Steps',
distance: 'Distance',
active: 'Active',
energy: 'Energy',
kilometers: '{count} km',
minutes: '{count} min',
kilocalories: '{count} kcal',
thisWeek: 'This week',
snapshot: 'Health snapshot',
condition: 'Condition',
recovery: 'Recovery',
currentHealth: 'Current health',
conditions: { good: 'Good', fair: 'Fair', low: 'Low' },
trends: {
title: 'Trends',
week: 'Week',
month: 'Month',
total: '{count} steps',
more: '{count}% more than last week',
less: '{count}% less than last week',
same: 'Same as last week',
dailyAverage: 'Daily average',
activeTime: 'Active time',
dailyActivity: 'Daily activity',
goal: 'Goal',
},
medicalId: {
title: 'Medical ID',
edit: 'Edit',
done: 'Done',
resident: 'Los Santos resident',
emergencyInformation: 'Emergency information',
bloodType: 'Blood type',
allergies: 'Allergies',
conditions: 'Conditions',
medication: 'Medication',
noneRecorded: 'None recorded',
emergencyContact: 'Emergency contact',
contactName: 'Contact name',
relation: 'Relation',
phoneNumber: 'Phone number',
emergencyCall: 'Emergency call',
callContact: 'Call emergency contact',
privacy:
'Information is stored with your character and can be shown to emergency services.',
saveFailed: 'Medical ID could not be saved.',
},
errors: {
invalid_phone_number: 'Enter a valid in-game phone number.',
invalid_request: 'Check the Medical ID fields.',
rate_limited: 'Please wait before trying again.',
request_failed: 'Health could not complete the request.',
},
}
const defaultLocales: LocaleTree = {
Apps: {
easyShare: {
@@ -549,6 +613,7 @@ const defaultLocales: LocaleTree = {
},
},
companies: companiesFallbackLocales,
health: healthFallbackLocales,
crewlink: {
name: 'CrewLink',
connecting: 'Connecting your crew...',
+1
View File
@@ -9,6 +9,7 @@ export type BuiltinPhoneAppId =
| 'clock'
| 'calendar'
| 'weather'
| 'health'
| 'banking'
| 'billing'
| 'garage'
+33
View File
@@ -0,0 +1,33 @@
export type HealthActivityDay = {
activeSeconds: number
date: string
distanceMeters: number
energyKcal: number
steps: number
}
export type HealthMedicalId = {
allergies: string
bloodType: string
conditions: string
emergencyName: string
emergencyPhone: string
emergencyRelation: string
medication: string
playerName: string
}
export type HealthSnapshot = {
healthPercent: number
}
export type HealthOverview = {
dailyStepGoal: number
days: HealthActivityDay[]
emergencyNumber: string
medicalId: HealthMedicalId
previousWeekSteps: number
snapshot: HealthSnapshot
}
export type HealthMedicalIdInput = Omit<HealthMedicalId, 'playerName'>
+14
View File
@@ -402,6 +402,20 @@ describe('home layout', () => {
expect(restored.hidden).not.toContain('phone')
})
it('reveals an existing shortcut when restoring a hidden app', () => {
const hidden = {
...defaults,
grid: [...defaults.grid],
hidden: [...defaults.hidden, 'health' as const],
}
hidden.grid[5] = 'health'
const restored = restoreHomeApp(hidden, 'health')
expect(restored.grid[5]).toBe('health')
expect(restored.hidden).not.toContain('health')
})
it('adds persistent empty pages up to the home screen limit', () => {
let layout = defaults
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
+5 -1
View File
@@ -601,7 +601,11 @@ export function restoreHomeApp(
layout.grid.some((item) => itemContainsApp(item, appId)) ||
layout.dock.some((item) => itemContainsApp(item, appId))
) {
return layout
if (!layout.hidden.includes(appId)) return layout
return {
...layout,
hidden: layout.hidden.filter((id) => id !== appId),
}
}
const grid = layout.grid.map(cloneItem)
placeInFirstEmptySlot(grid, appId)
+1
View File
@@ -101,6 +101,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
flare: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
health: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
memory: { enabled: true, sounds: true },
'number-merge': { enabled: true, sounds: true },
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./HealthApp.vue', import.meta.url), 'utf8')
describe('HealthApp Sky UI contract', () => {
it('uses the shared page, navbar, scroll owner, and pill navigation', () => {
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('with-tabbar')
expect(source).toContain('<SkyPillNavigation')
expect(source).not.toContain(
'padding: 0 var(--sky-page-gutter) var(--sky-page-space)',
)
})
it('keeps every user-facing label in the locale tree', () => {
expect(source).toContain("phone.t('Apps.health.name')")
expect(source).toContain("phone.t('Apps.health.tabs.today')")
expect(source).toContain("phone.t('Apps.health.medicalId.title')")
})
it('uses the real Health NUI data flow', () => {
expect(source).toContain('void health.load()')
expect(source).toContain("nuiCall('calls:dial'")
expect(source).toContain('health.saveMedicalId')
})
})
File diff suppressed because it is too large Load Diff
+53
View File
@@ -40,6 +40,41 @@ function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
function healthDate(dayOffset) {
const value = new Date()
value.setDate(value.getDate() + dayOffset)
return value.toISOString().slice(0, 10)
}
const healthStepSamples = [4289, 6312, 3421, 7024, 8618, 4302, 6420]
let healthMedicalId = {
allergies: '',
bloodType: 'O+',
conditions: '',
emergencyName: 'Jamie Morgan',
emergencyPhone: '5550102211',
emergencyRelation: 'Sibling',
medication: '',
playerName: 'Alex Morgan',
}
function healthOverview() {
return {
dailyStepGoal: 8000,
days: healthStepSamples.map((steps, index) => ({
activeSeconds: Math.round(steps * 0.36),
date: healthDate(index - 6),
distanceMeters: Math.round(steps * 0.75),
energyKcal: Math.round(steps * 0.045),
steps,
})),
emergencyNumber: '911',
medicalId: healthMedicalId,
previousWeekSteps: 36980,
snapshot: { healthPercent: 96 },
}
}
function memoWaveform(phase = 0) {
return Array.from({ length: 48 }, (_, index) =>
Number(
@@ -7532,6 +7567,24 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'health:overview') {
response.json({ success: true, data: healthOverview() })
return
}
if (endpoint === 'health:save-profile') {
healthMedicalId = {
...healthMedicalId,
allergies: String(request.body.allergies ?? ''),
bloodType: String(request.body.bloodType ?? ''),
conditions: String(request.body.conditions ?? ''),
emergencyName: String(request.body.emergencyName ?? ''),
emergencyPhone: String(request.body.emergencyPhone ?? ''),
emergencyRelation: String(request.body.emergencyRelation ?? ''),
medication: String(request.body.medication ?? ''),
}
response.json({ success: true, data: healthMedicalId })
return
}
if (endpoint === 'billing:overview') {
response.json({
success: true,
+24
View File
@@ -7,6 +7,7 @@ const browserDataRequests = [
['development:bootstrap', {}],
['account:devices', {}],
['banking:overview', {}],
['health:overview', {}],
['billing:overview', {}],
['billing:list', { filter: 'all', limit: 20, offset: 0 }],
['calendar:list', { endsAt: 4_102_444_800, startsAt: 0 }],
@@ -321,6 +322,29 @@ async function verifyStatefulActions(baseUrl) {
)
assert.equal(bankingAfter.bank, bankingBefore.bank - 125)
const health = await expectSuccess(baseUrl, 'health:overview', {}, true)
assert.equal(health.dailyStepGoal, 8000)
assert.equal(health.days.length, 7)
assert.equal(health.days.at(-1).steps, 6420)
assert.equal(health.snapshot.healthPercent, 96)
const medicalId = await expectSuccess(
baseUrl,
'health:save-profile',
{
allergies: 'Penicillin',
bloodType: 'A+',
conditions: 'Asthma',
emergencyName: 'Jamie Morgan',
emergencyPhone: '5550102211',
emergencyRelation: 'Sibling',
medication: 'Inhaler',
},
true,
)
assert.equal(medicalId.bloodType, 'A+')
assert.equal(medicalId.allergies, 'Penicillin')
let radio = await expectSuccess(
baseUrl,
'radio:connect',
+10
View File
@@ -270,6 +270,16 @@ Config.Banking = {
HistoryLimit = 50,
}
Config.Health = {
DailyStepGoal = 8000,
SampleIntervalMs = 500,
ReportIntervalSeconds = 30,
ReportsPerMinute = 4,
MaximumSpeedMetersPerSecond = 12.0,
EmergencyNumber = "911",
ProfileTextMaxLength = 500,
}
Config.Billing = {
Enabled = true,
Currency = "$",
+45
View File
@@ -184,6 +184,51 @@ Locales["en"] = {
},
},
Apps = {
health = {
name = "Health",
navigation = "Health navigation",
tabs = { today = "Today", trends = "Trends", medicalId = "Medical ID" },
loading = "Loading health data...",
errorTitle = "Health is unavailable",
errorBody = "Your activity data could not be loaded.",
tryAgain = "Try Again",
goal = "of {count}",
steps = "Steps",
distance = "Distance",
active = "Active",
energy = "Energy",
kilometers = "{count} km",
minutes = "{count} min",
kilocalories = "{count} kcal",
thisWeek = "This week",
snapshot = "Health snapshot",
condition = "Condition",
recovery = "Recovery",
currentHealth = "Current health",
conditions = { good = "Good", fair = "Fair", low = "Low" },
trends = {
title = "Trends", week = "Week", month = "Month", total = "{count} steps",
more = "{count}% more than last week", less = "{count}% less than last week",
same = "Same as last week", dailyAverage = "Daily average", activeTime = "Active time",
dailyActivity = "Daily activity", goal = "Goal",
},
medicalId = {
title = "Medical ID", edit = "Edit", done = "Done", resident = "Los Santos resident",
emergencyInformation = "Emergency information", bloodType = "Blood type",
allergies = "Allergies", conditions = "Conditions", medication = "Medication",
noneRecorded = "None recorded", emergencyContact = "Emergency contact",
contactName = "Contact name", relation = "Relation", phoneNumber = "Phone number",
emergencyCall = "Emergency call", callContact = "Call emergency contact",
privacy = "Information is stored with your character and can be shown to emergency services.",
saveFailed = "Medical ID could not be saved.",
},
errors = {
invalid_phone_number = "Enter a valid in-game phone number.",
invalid_request = "Check the Medical ID fields.",
rate_limited = "Please wait before trying again.",
request_failed = "Health could not complete the request.",
},
},
customApps = {
loading = "Opening app...",
unavailableTitle = "App unavailable",
+2
View File
@@ -37,6 +37,7 @@ client_scripts {
'source/client/skyride.lua',
'source/client/housing.lua',
'source/client/crewlink.lua',
'source/client/health.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
@@ -87,6 +88,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/health.lua',
'source/server/billing.lua',
'source/server/garage.lua',
'source/server/housing.lua',
+111
View File
@@ -0,0 +1,111 @@
local pending_steps = 0
local pending_distance = 0.0
local pending_active_seconds = 0.0
local step_progress = 0.0
local last_coords = nil
local last_report_at = GetGameTimer()
local function health_snapshot()
local ped = PlayerPedId()
if not DoesEntityExist(ped) then
return { healthPercent = 0 }
end
local health = GetEntityHealth(ped)
local maximum = math.max(1, GetEntityMaxHealth(ped))
local base = maximum > 100 and 100 or 0
local percentage = math.floor(math.max(0, math.min(100, (health - base) / (maximum - base) * 100)) + 0.5)
return { healthPercent = percentage }
end
local function flush_activity()
last_report_at = GetGameTimer()
local distance_meters = math.floor(pending_distance + 0.5)
local active_seconds = math.floor(pending_active_seconds + 0.5)
if pending_steps == 0 and distance_meters == 0 and active_seconds == 0 then
return
end
TriggerServerEvent("sky_phone:health:record-activity", {
steps = pending_steps,
distanceMeters = distance_meters,
activeSeconds = active_seconds,
})
pending_steps = 0
pending_distance = 0.0
pending_active_seconds = 0.0
end
CreateThread(function()
while true do
Wait(Config.Health.SampleIntervalMs)
local ped = PlayerPedId()
if DoesEntityExist(ped) then
local coords = GetEntityCoords(ped)
local is_moving_on_foot = IsPedOnFoot(ped)
and not IsPedDeadOrDying(ped, true)
and not IsPedFalling(ped)
and not IsPedRagdoll(ped)
and (IsPedWalking(ped) or IsPedRunning(ped) or IsPedSprinting(ped))
if last_coords and is_moving_on_foot then
local delta_x = coords.x - last_coords.x
local delta_y = coords.y - last_coords.y
local distance = math.sqrt(delta_x * delta_x + delta_y * delta_y)
local maximum_sample_distance = Config.Health.MaximumSpeedMetersPerSecond
* Config.Health.SampleIntervalMs / 1000.0
if distance >= 0.04 and distance <= maximum_sample_distance then
local stride_length = 0.75
if IsPedSprinting(ped) then
stride_length = 1.15
elseif IsPedRunning(ped) then
stride_length = 1.0
end
pending_distance = pending_distance + distance
pending_active_seconds = pending_active_seconds + Config.Health.SampleIntervalMs / 1000.0
step_progress = step_progress + distance
while step_progress >= stride_length do
step_progress = step_progress - stride_length
pending_steps = pending_steps + 1
end
end
end
last_coords = coords
else
last_coords = nil
end
if GetGameTimer() - last_report_at >= Config.Health.ReportIntervalSeconds * 1000 then
flush_activity()
end
end
end)
AddEventHandler("playerSpawned", function()
last_coords = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
flush_activity()
end
end)
RegisterNUICallback("health:overview", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:health:overview", data or {})
if result and result.success and result.data then
result.data.snapshot = health_snapshot()
end
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("health:save-profile", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:health:save-profile", data or {})
cb(result or { success = false, error = "request_failed" })
end)
RegisterNetEvent("sky_phone:health:changed", function()
SendNUIMessage({ type = "health:changed" })
end)
+37
View File
@@ -532,6 +532,43 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_health_daily",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "activity_date", type = "DATE NOT NULL" },
{ name = "steps", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "distance_meters", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "active_seconds", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "energy_kcal", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_health_daily", columns = "(`owner_identifier`, `activity_date`)" },
},
indexes = {
{ name = "idx_sky_phone_health_history", columns = "(`owner_identifier`, `activity_date`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_health_profiles",
columns = {
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "blood_type", type = "VARCHAR(3) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "allergies", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "conditions", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "medication", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "emergency_name", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "emergency_relation", type = "VARCHAR(40) NOT NULL DEFAULT ''" },
{ name = "emergency_phone", type = "VARCHAR(24) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "owner_identifier",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_invoices",
columns = {
+268
View File
@@ -0,0 +1,268 @@
Bridge.Database.AfterMigration("sky_phone", function()
local activity_reports = {}
local allowed_blood_types = {
[""] = true,
["A+"] = true,
["A-"] = true,
["B+"] = true,
["B-"] = true,
["AB+"] = true,
["AB-"] = true,
["O+"] = true,
["O-"] = true,
}
local function character_identifier(source, require_session)
if require_session then
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "health_unavailable" }
end
return identifier
end
local function player_name(source)
local firstname = Bridge.Framework.GetFirstname(source)
local lastname = Bridge.Framework.GetLastname(source)
local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$")
if name == "" then
return GetPlayerName(source) or ("Player %s"):format(source)
end
return name
end
local function clean_text(value, maximum_length)
if type(value) ~= "string" or value:find("[%z\1-\31]") then
return nil
end
local cleaned = value:match("^%s*(.-)%s*$")
if #cleaned > maximum_length then
return nil
end
return cleaned
end
local function empty_profile(source)
return {
playerName = player_name(source),
bloodType = "",
allergies = "",
conditions = "",
medication = "",
emergencyName = "",
emergencyRelation = "",
emergencyPhone = "",
}
end
local function profile_for(source, identifier)
local rows = Bridge.Database.Query([[
SELECT `blood_type`, `allergies`, `conditions`, `medication`,
`emergency_name`, `emergency_relation`, `emergency_phone`
FROM `sky_phone_health_profiles`
WHERE `owner_identifier` = ?
LIMIT 1
]], { identifier })
local row = rows[1]
if not row then
return empty_profile(source)
end
return {
playerName = player_name(source),
bloodType = row.blood_type or "",
allergies = row.allergies or "",
conditions = row.conditions or "",
medication = row.medication or "",
emergencyName = row.emergency_name or "",
emergencyRelation = row.emergency_relation or "",
emergencyPhone = row.emergency_phone or "",
}
end
local function day_key(timestamp)
return os.date("%Y-%m-%d", timestamp)
end
local function activity_history(identifier)
local rows = Bridge.Database.Query([[
SELECT DATE_FORMAT(`activity_date`, '%Y-%m-%d') AS `activity_date`,
`steps`, `distance_meters`, `active_seconds`, `energy_kcal`
FROM `sky_phone_health_daily`
WHERE `owner_identifier` = ?
AND `activity_date` >= DATE_SUB(CURDATE(), INTERVAL 13 DAY)
ORDER BY `activity_date` ASC
]], { identifier })
local by_date = {}
for _, row in ipairs(rows) do
by_date[row.activity_date] = row
end
local now = os.time()
local days = {}
local previous_week_steps = 0
for days_ago = 13, 0, -1 do
local date = day_key(now - days_ago * 86400)
local row = by_date[date]
local item = {
date = date,
steps = math.max(0, tonumber(row and row.steps) or 0),
distanceMeters = math.max(0, tonumber(row and row.distance_meters) or 0),
activeSeconds = math.max(0, tonumber(row and row.active_seconds) or 0),
energyKcal = math.max(0, tonumber(row and row.energy_kcal) or 0),
}
if days_ago >= 7 then
previous_week_steps = previous_week_steps + item.steps
else
days[#days + 1] = item
end
end
return days, previous_week_steps
end
local function overview(source, identifier)
local days, previous_week_steps = activity_history(identifier)
return {
dailyStepGoal = Config.Health.DailyStepGoal,
days = days,
previousWeekSteps = previous_week_steps,
medicalId = profile_for(source, identifier),
emergencyNumber = Config.Health.EmergencyNumber,
}
end
Bridge.Callbacks.Register("sky_phone:health:overview", function(source)
if not SkyPhone.AllowOperation(source, "health_overview", 60, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = character_identifier(source, true)
if not identifier then
return error_response
end
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:health:save-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "health_profile_save", 12, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = character_identifier(source, true)
if not identifier then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local blood_type = clean_text(data.bloodType, 3)
local allergies = clean_text(data.allergies, Config.Health.ProfileTextMaxLength)
local conditions = clean_text(data.conditions, Config.Health.ProfileTextMaxLength)
local medication = clean_text(data.medication, Config.Health.ProfileTextMaxLength)
local emergency_name = clean_text(data.emergencyName, 80)
local emergency_relation = clean_text(data.emergencyRelation, 40)
local emergency_phone = clean_text(data.emergencyPhone, 24)
if not blood_type or not allowed_blood_types[blood_type]
or not allergies or not conditions or not medication
or not emergency_name or not emergency_relation or not emergency_phone
then
return { success = false, error = "invalid_request" }
end
if emergency_phone ~= "" then
emergency_phone = SkyPhoneSimNumber.Normalize(
emergency_phone,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not emergency_phone then
return { success = false, error = "invalid_phone_number" }
end
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_health_profiles`
(`owner_identifier`, `blood_type`, `allergies`, `conditions`, `medication`,
`emergency_name`, `emergency_relation`, `emergency_phone`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`blood_type` = VALUES(`blood_type`),
`allergies` = VALUES(`allergies`),
`conditions` = VALUES(`conditions`),
`medication` = VALUES(`medication`),
`emergency_name` = VALUES(`emergency_name`),
`emergency_relation` = VALUES(`emergency_relation`),
`emergency_phone` = VALUES(`emergency_phone`)
]], {
identifier,
blood_type,
allergies,
conditions,
medication,
emergency_name,
emergency_relation,
emergency_phone,
})
return { success = true, data = profile_for(source, identifier) }
end)
RegisterNetEvent("sky_phone:health:record-activity", function(data)
local player_source = source
if type(data) ~= "table"
or type(data.steps) ~= "number" or data.steps ~= math.floor(data.steps)
or type(data.distanceMeters) ~= "number" or data.distanceMeters ~= math.floor(data.distanceMeters)
or type(data.activeSeconds) ~= "number" or data.activeSeconds ~= math.floor(data.activeSeconds)
or data.steps < 0 or data.distanceMeters < 0 or data.activeSeconds < 0
then
Bridge.Debug("warn", "[sky_phone] Rejected malformed health activity from source %s.", tostring(player_source))
return
end
if not SkyPhone.AllowOperation(player_source, "health_activity", Config.Health.ReportsPerMinute, 60) then
return
end
local identifier = character_identifier(player_source, false)
if not identifier then
return
end
local now = os.time()
local previous = activity_reports[player_source]
local elapsed = previous and math.max(1, now - previous) or Config.Health.ReportIntervalSeconds + 5
activity_reports[player_source] = now
local maximum_distance = math.ceil(elapsed * Config.Health.MaximumSpeedMetersPerSecond)
local maximum_steps = math.ceil(data.distanceMeters / 0.35) + 4
if data.distanceMeters > maximum_distance
or data.activeSeconds > elapsed + 5
or data.distanceMeters > data.activeSeconds * Config.Health.MaximumSpeedMetersPerSecond + 5
or data.steps > maximum_steps
then
Bridge.Debug("warn", "[sky_phone] Rejected implausible health activity from source %s.", tostring(player_source))
return
end
if data.steps == 0 and data.distanceMeters == 0 and data.activeSeconds == 0 then
return
end
local energy_kcal = math.floor(data.distanceMeters * 0.06 + 0.5)
Bridge.Database.Query([[
INSERT INTO `sky_phone_health_daily`
(`owner_identifier`, `activity_date`, `steps`, `distance_meters`, `active_seconds`, `energy_kcal`)
VALUES (?, CURDATE(), ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`steps` = `steps` + VALUES(`steps`),
`distance_meters` = `distance_meters` + VALUES(`distance_meters`),
`active_seconds` = `active_seconds` + VALUES(`active_seconds`),
`energy_kcal` = `energy_kcal` + VALUES(`energy_kcal`)
]], { identifier, data.steps, data.distanceMeters, data.activeSeconds, energy_kcal })
TriggerClientEvent("sky_phone:health:changed", player_source)
end)
AddEventHandler("playerDropped", function()
activity_reports[source] = nil
end)
end)
+27
View File
@@ -267,6 +267,33 @@ CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` (
KEY `idx_sky_phone_bank_reference` (`reference`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_health_daily` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`activity_date` DATE NOT NULL,
`steps` INT UNSIGNED NOT NULL DEFAULT 0,
`distance_meters` INT UNSIGNED NOT NULL DEFAULT 0,
`active_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
`energy_kcal` INT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_health_daily` (`owner_identifier`, `activity_date`),
KEY `idx_sky_phone_health_history` (`owner_identifier`, `activity_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_health_profiles` (
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`blood_type` VARCHAR(3) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '',
`allergies` VARCHAR(500) NOT NULL DEFAULT '',
`conditions` VARCHAR(500) NOT NULL DEFAULT '',
`medication` VARCHAR(500) NOT NULL DEFAULT '',
`emergency_name` VARCHAR(80) NOT NULL DEFAULT '',
`emergency_relation` VARCHAR(40) NOT NULL DEFAULT '',
`emergency_phone` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`owner_identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_identifier` VARCHAR(80) NOT NULL,