diff --git a/frontend/src/assets/img/app-icons/health.webp b/frontend/src/assets/img/app-icons/health.webp new file mode 100644 index 0000000..be26df8 Binary files /dev/null and b/frontend/src/assets/img/app-icons/health.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 09fd166..b7fe0a7 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index dd63476..69b1cf3 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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([ + { + 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 = 'settings', 'map', 'calendar', + 'health', ]) export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = @@ -654,6 +671,7 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet = 'phone', 'messages', 'mail', + 'health', ]) export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 5ac2891..9b14196 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -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) diff --git a/frontend/src/stores/health.test.ts b/frontend/src/stores/health.test.ts new file mode 100644 index 0000000..729a192 --- /dev/null +++ b/frontend/src/stores/health.test.ts @@ -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+') + }) +}) diff --git a/frontend/src/stores/health.ts b/frontend/src/stores/health.ts new file mode 100644 index 0000000..bad35b9 --- /dev/null +++ b/frontend/src/stores/health.ts @@ -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 { + const generation = ++this.requestGeneration + this.isLoading = true + const response = await nuiCall('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 { + if (this.isSaving) return false + this.isSaving = true + const response = await nuiCall( + '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 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 34baa40..231868b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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...', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 24829dd..51d903e 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -9,6 +9,7 @@ export type BuiltinPhoneAppId = | 'clock' | 'calendar' | 'weather' + | 'health' | 'banking' | 'billing' | 'garage' diff --git a/frontend/src/types/health.ts b/frontend/src/types/health.ts new file mode 100644 index 0000000..d28fa9b --- /dev/null +++ b/frontend/src/types/health.ts @@ -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 diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index a6a8d88..402bb1b 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -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) { diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 0826cfd..91ed923 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -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) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 7b00633..d2a3335 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/HealthApp.contract.test.ts b/frontend/src/views/apps/HealthApp.contract.test.ts new file mode 100644 index 0000000..3950b7f --- /dev/null +++ b/frontend/src/views/apps/HealthApp.contract.test.ts @@ -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(' { + 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') + }) +}) diff --git a/frontend/src/views/apps/HealthApp.vue b/frontend/src/views/apps/HealthApp.vue new file mode 100644 index 0000000..34aa4d1 --- /dev/null +++ b/frontend/src/views/apps/HealthApp.vue @@ -0,0 +1,1179 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 1041411..a3c7411 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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, diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 79206a1..9f47480 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -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', diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index a865cf5..845fce6 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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 = "$", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 002bfa1..db87d87 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index c1d31a8..efe1958 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/health.lua b/sky_phone/source/client/health.lua new file mode 100644 index 0000000..94a8d22 --- /dev/null +++ b/sky_phone/source/client/health.lua @@ -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) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 65081d4..2305a49 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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 = { diff --git a/sky_phone/source/server/health.lua b/sky_phone/source/server/health.lua new file mode 100644 index 0000000..f670267 --- /dev/null +++ b/sky_phone/source/server/health.lua @@ -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) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index c39f40f..96ca343 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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,