feat: frontend using django-vite + blabla
This commit is contained in:
parent
63a5a69c67
commit
c06b184a2d
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,3 +14,6 @@ db.sqlite3
|
|||||||
static/
|
static/
|
||||||
.env
|
.env
|
||||||
reminders.log
|
reminders.log
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
|||||||
23
Makefile
23
Makefile
@ -19,7 +19,7 @@ help: ## Show this help
|
|||||||
# --- Setup -----------------------------------------------------------------
|
# --- Setup -----------------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: check-uv ## Install dependencies, create .env, migrate, collect static files
|
install: check-uv frontend ## Install dependencies, build the frontend, migrate, collect static files
|
||||||
$(UV) sync
|
$(UV) sync
|
||||||
@$(MAKE) --no-print-directory env # after sync: it needs Django importable
|
@$(MAKE) --no-print-directory env # after sync: it needs Django importable
|
||||||
$(MANAGE) migrate
|
$(MANAGE) migrate
|
||||||
@ -107,6 +107,25 @@ cron-remove: ## Remove the reminder cron job
|
|||||||
cron-test: ## Run the reminder job once, reporting what it would send
|
cron-test: ## Run the reminder job once, reporting what it would send
|
||||||
$(REMINDER_SH) --dry-run
|
$(REMINDER_SH) --dry-run
|
||||||
|
|
||||||
|
# --- Frontend --------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: frontend
|
||||||
|
frontend: ## Install npm dependencies and build the Vue frontend
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
|
# Only build when no bundle exists yet: tests render the shell template, which
|
||||||
|
# needs the Vite manifest, but should not pay for a rebuild on every run.
|
||||||
|
frontend/dist/.vite/manifest.json:
|
||||||
|
@$(MAKE) --no-print-directory frontend
|
||||||
|
|
||||||
|
.PHONY: front-dev
|
||||||
|
front-dev: ## Run the Vite dev server with hot reload (pair with: DJANGO_VITE_DEV=1 make run)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
.PHONY: api-client
|
||||||
|
api-client: ## Regenerate the typed TS API client (needs 'make run' in another terminal)
|
||||||
|
cd frontend && npm run generate:api
|
||||||
|
|
||||||
# --- Development -----------------------------------------------------------
|
# --- Development -----------------------------------------------------------
|
||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
@ -114,7 +133,7 @@ run: ## Run the development server
|
|||||||
$(MANAGE) runserver $(PORT)
|
$(MANAGE) runserver $(PORT)
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## Run the test suite
|
test: frontend/dist/.vite/manifest.json ## Run the test suite
|
||||||
$(MANAGE) test
|
$(MANAGE) test
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|||||||
9
frontend/openapi-ts.config.ts
Normal file
9
frontend/openapi-ts.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "@hey-api/openapi-ts";
|
||||||
|
|
||||||
|
// Reads the schema from the running dev server: start `make run` first, then
|
||||||
|
// `npm run generate:api` (or `make api-client`) after changing tracker/api.py.
|
||||||
|
export default defineConfig({
|
||||||
|
input: "http://127.0.0.1:8000/api/openapi.json",
|
||||||
|
output: "src/api",
|
||||||
|
plugins: ["@hey-api/client-fetch"],
|
||||||
|
});
|
||||||
7811
frontend/package-lock.json
generated
Normal file
7811
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "osiris-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && tsc -p tsconfig.sw.json && vite build",
|
||||||
|
"generate:api": "openapi-ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.84.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vue/tsconfig": "^0.8.0",
|
||||||
|
"daisyui": "^5.1.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^7.1.0",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vue-tsc": "^3.1.0",
|
||||||
|
"workbox-core": "^7.4.1",
|
||||||
|
"workbox-precaching": "^7.4.1",
|
||||||
|
"workbox-routing": "^7.4.1",
|
||||||
|
"workbox-strategies": "^7.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1797
frontend/pnpm-lock.yaml
Normal file
1797
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/src/App.vue
Normal file
32
frontend/src/App.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useOnline, useTitle } from "@vueuse/core";
|
||||||
|
|
||||||
|
const online = useOnline();
|
||||||
|
useTitle("Osiris");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-xl p-3 pb-24">
|
||||||
|
<div v-if="!online" class="alert alert-warning mb-3" role="alert">
|
||||||
|
Offline — changes will not be saved until the connection is back.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-view />
|
||||||
|
|
||||||
|
<nav class="dock dock-sm border-t border-base-300 bg-base-100">
|
||||||
|
<router-link :to="{ name: 'today' }" :class="{ 'dock-active': $route.name === 'today' }">
|
||||||
|
<span aria-hidden="true">💊</span>
|
||||||
|
<span class="dock-label">Today</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'recap' }" :class="{ 'dock-active': $route.name === 'recap' }">
|
||||||
|
<span aria-hidden="true">📈</span>
|
||||||
|
<span class="dock-label">Recap</span>
|
||||||
|
</router-link>
|
||||||
|
<!-- A full page load on purpose: the Django admin is outside the SPA. -->
|
||||||
|
<a href="/admin/">
|
||||||
|
<span aria-hidden="true">⚙️</span>
|
||||||
|
<span class="dock-label">Admin</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
frontend/src/api/client.gen.ts
Normal file
16
frontend/src/api/client.gen.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||||
|
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export const client = createClient(createConfig<ClientOptions2>());
|
||||||
268
frontend/src/api/client/client.gen.ts
Normal file
268
frontend/src/api/client/client.gen.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||||
|
import type { HttpMethod } from '../core/types.gen';
|
||||||
|
import { getValidRequestBody } from '../core/utils.gen';
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
Config,
|
||||||
|
RequestOptions,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
} from './types.gen';
|
||||||
|
import {
|
||||||
|
buildUrl,
|
||||||
|
createConfig,
|
||||||
|
createInterceptors,
|
||||||
|
getParseAs,
|
||||||
|
mergeConfigs,
|
||||||
|
mergeHeaders,
|
||||||
|
setAuthParams,
|
||||||
|
} from './utils.gen';
|
||||||
|
|
||||||
|
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||||
|
body?: any;
|
||||||
|
headers: ReturnType<typeof mergeHeaders>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createClient = (config: Config = {}): Client => {
|
||||||
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
|
const setConfig = (config: Config): Config => {
|
||||||
|
_config = mergeConfigs(_config, config);
|
||||||
|
return getConfig();
|
||||||
|
};
|
||||||
|
|
||||||
|
const interceptors = createInterceptors<
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
unknown,
|
||||||
|
ResolvedRequestOptions
|
||||||
|
>();
|
||||||
|
|
||||||
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
|
const opts = {
|
||||||
|
..._config,
|
||||||
|
...options,
|
||||||
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
|
serializedBody: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.security) {
|
||||||
|
await setAuthParams({
|
||||||
|
...opts,
|
||||||
|
security: opts.security,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.requestValidator) {
|
||||||
|
await opts.requestValidator(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
|
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
|
if (opts.body === undefined || opts.serializedBody === '') {
|
||||||
|
opts.headers.delete('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildUrl(opts);
|
||||||
|
|
||||||
|
return { opts, url };
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: Client['request'] = async (options) => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
const requestInit: ReqInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...opts,
|
||||||
|
body: getValidRequestBody(opts),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = opts.fetch!;
|
||||||
|
let response = await _fetch(request);
|
||||||
|
|
||||||
|
for (const fn of interceptors.response.fns) {
|
||||||
|
if (fn) {
|
||||||
|
response = await fn(response, request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parseAs =
|
||||||
|
(opts.parseAs === 'auto'
|
||||||
|
? getParseAs(response.headers.get('Content-Type'))
|
||||||
|
: opts.parseAs) ?? 'json';
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.status === 204 ||
|
||||||
|
response.headers.get('Content-Length') === '0'
|
||||||
|
) {
|
||||||
|
let emptyData: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'text':
|
||||||
|
emptyData = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'formData':
|
||||||
|
emptyData = new FormData();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
emptyData = response.body;
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
default:
|
||||||
|
emptyData = {};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? emptyData
|
||||||
|
: {
|
||||||
|
data: emptyData,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
switch (parseAs) {
|
||||||
|
case 'arrayBuffer':
|
||||||
|
case 'blob':
|
||||||
|
case 'formData':
|
||||||
|
case 'json':
|
||||||
|
case 'text':
|
||||||
|
data = await response[parseAs]();
|
||||||
|
break;
|
||||||
|
case 'stream':
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? response.body
|
||||||
|
: {
|
||||||
|
data: response.body,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseAs === 'json') {
|
||||||
|
if (opts.responseValidator) {
|
||||||
|
await opts.responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.responseTransformer) {
|
||||||
|
data = await opts.responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? data
|
||||||
|
: {
|
||||||
|
data,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textError = await response.text();
|
||||||
|
let jsonError: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jsonError = JSON.parse(textError);
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = jsonError ?? textError;
|
||||||
|
let finalError = error;
|
||||||
|
|
||||||
|
for (const fn of interceptors.error.fns) {
|
||||||
|
if (fn) {
|
||||||
|
finalError = (await fn(error, response, request, opts)) as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalError = finalError || ({} as string);
|
||||||
|
|
||||||
|
if (opts.throwOnError) {
|
||||||
|
throw finalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we probably want to return error and improve types
|
||||||
|
return opts.responseStyle === 'data'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
error: finalError,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMethodFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
|
request({ ...options, method });
|
||||||
|
|
||||||
|
const makeSseFn =
|
||||||
|
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
|
const { opts, url } = await beforeRequest(options);
|
||||||
|
return createSseClient({
|
||||||
|
...opts,
|
||||||
|
body: opts.body as BodyInit | null | undefined,
|
||||||
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
|
method,
|
||||||
|
onRequest: async (url, init) => {
|
||||||
|
let request = new Request(url, init);
|
||||||
|
for (const fn of interceptors.request.fns) {
|
||||||
|
if (fn) {
|
||||||
|
request = await fn(request, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
connect: makeMethodFn('CONNECT'),
|
||||||
|
delete: makeMethodFn('DELETE'),
|
||||||
|
get: makeMethodFn('GET'),
|
||||||
|
getConfig,
|
||||||
|
head: makeMethodFn('HEAD'),
|
||||||
|
interceptors,
|
||||||
|
options: makeMethodFn('OPTIONS'),
|
||||||
|
patch: makeMethodFn('PATCH'),
|
||||||
|
post: makeMethodFn('POST'),
|
||||||
|
put: makeMethodFn('PUT'),
|
||||||
|
request,
|
||||||
|
setConfig,
|
||||||
|
sse: {
|
||||||
|
connect: makeSseFn('CONNECT'),
|
||||||
|
delete: makeSseFn('DELETE'),
|
||||||
|
get: makeSseFn('GET'),
|
||||||
|
head: makeSseFn('HEAD'),
|
||||||
|
options: makeSseFn('OPTIONS'),
|
||||||
|
patch: makeSseFn('PATCH'),
|
||||||
|
post: makeSseFn('POST'),
|
||||||
|
put: makeSseFn('PUT'),
|
||||||
|
trace: makeSseFn('TRACE'),
|
||||||
|
},
|
||||||
|
trace: makeMethodFn('TRACE'),
|
||||||
|
} as Client;
|
||||||
|
};
|
||||||
25
frontend/src/api/client/index.ts
Normal file
25
frontend/src/api/client/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type { Auth } from '../core/auth.gen';
|
||||||
|
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
export {
|
||||||
|
formDataBodySerializer,
|
||||||
|
jsonBodySerializer,
|
||||||
|
urlSearchParamsBodySerializer,
|
||||||
|
} from '../core/bodySerializer.gen';
|
||||||
|
export { buildClientParams } from '../core/params.gen';
|
||||||
|
export { createClient } from './client.gen';
|
||||||
|
export type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
CreateClientConfig,
|
||||||
|
Options,
|
||||||
|
OptionsLegacyParser,
|
||||||
|
RequestOptions,
|
||||||
|
RequestResult,
|
||||||
|
ResolvedRequestOptions,
|
||||||
|
ResponseStyle,
|
||||||
|
TDataShape,
|
||||||
|
} from './types.gen';
|
||||||
|
export { createConfig, mergeHeaders } from './utils.gen';
|
||||||
268
frontend/src/api/client/types.gen.ts
Normal file
268
frontend/src/api/client/types.gen.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth } from '../core/auth.gen';
|
||||||
|
import type {
|
||||||
|
ServerSentEventsOptions,
|
||||||
|
ServerSentEventsResult,
|
||||||
|
} from '../core/serverSentEvents.gen';
|
||||||
|
import type {
|
||||||
|
Client as CoreClient,
|
||||||
|
Config as CoreConfig,
|
||||||
|
} from '../core/types.gen';
|
||||||
|
import type { Middleware } from './utils.gen';
|
||||||
|
|
||||||
|
export type ResponseStyle = 'data' | 'fields';
|
||||||
|
|
||||||
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
|
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||||
|
CoreConfig {
|
||||||
|
/**
|
||||||
|
* Base URL for all requests made by this client.
|
||||||
|
*/
|
||||||
|
baseUrl?: T['baseUrl'];
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||||
|
* options won't have any effect.
|
||||||
|
*
|
||||||
|
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||||
|
*/
|
||||||
|
next?: never;
|
||||||
|
/**
|
||||||
|
* Return the response data parsed in a specified format. By default, `auto`
|
||||||
|
* will infer the appropriate method from the `Content-Type` response header.
|
||||||
|
* You can override this behavior with any of the {@link Body} methods.
|
||||||
|
* Select `stream` if you don't want to parse response data at all.
|
||||||
|
*
|
||||||
|
* @default 'auto'
|
||||||
|
*/
|
||||||
|
parseAs?:
|
||||||
|
| 'arrayBuffer'
|
||||||
|
| 'auto'
|
||||||
|
| 'blob'
|
||||||
|
| 'formData'
|
||||||
|
| 'json'
|
||||||
|
| 'stream'
|
||||||
|
| 'text';
|
||||||
|
/**
|
||||||
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
|
*
|
||||||
|
* @default 'fields'
|
||||||
|
*/
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
/**
|
||||||
|
* Throw an error instead of returning it in the response?
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
throwOnError?: T['throwOnError'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions<
|
||||||
|
TData = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends Config<{
|
||||||
|
responseStyle: TResponseStyle;
|
||||||
|
throwOnError: ThrowOnError;
|
||||||
|
}>,
|
||||||
|
Pick<
|
||||||
|
ServerSentEventsOptions<TData>,
|
||||||
|
| 'onSseError'
|
||||||
|
| 'onSseEvent'
|
||||||
|
| 'sseDefaultRetryDelay'
|
||||||
|
| 'sseMaxRetryAttempts'
|
||||||
|
| 'sseMaxRetryDelay'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Any body that you want to add to your request.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
|
*/
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Security mechanism(s) to use for the request.
|
||||||
|
*/
|
||||||
|
security?: ReadonlyArray<Auth>;
|
||||||
|
url: Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedRequestOptions<
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
Url extends string = string,
|
||||||
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
|
serializedBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestResult<
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = ThrowOnError extends true
|
||||||
|
? Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
? TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData
|
||||||
|
: {
|
||||||
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
: Promise<
|
||||||
|
TResponseStyle extends 'data'
|
||||||
|
?
|
||||||
|
| (TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData)
|
||||||
|
| undefined
|
||||||
|
: (
|
||||||
|
| {
|
||||||
|
data: TData extends Record<string, unknown>
|
||||||
|
? TData[keyof TData]
|
||||||
|
: TData;
|
||||||
|
error: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data: undefined;
|
||||||
|
error: TError extends Record<string, unknown>
|
||||||
|
? TError[keyof TError]
|
||||||
|
: TError;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
request: Request;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
responseStyle?: ResponseStyle;
|
||||||
|
throwOnError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MethodFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type SseFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||||
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
|
type RequestFn = <
|
||||||
|
TData = unknown,
|
||||||
|
TError = unknown,
|
||||||
|
ThrowOnError extends boolean = false,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
>(
|
||||||
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||||
|
Pick<
|
||||||
|
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||||
|
'method'
|
||||||
|
>,
|
||||||
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
type BuildUrlFn = <
|
||||||
|
TData extends {
|
||||||
|
body?: unknown;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
options: Pick<TData, 'url'> & Options<TData>,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export type Client = CoreClient<
|
||||||
|
RequestFn,
|
||||||
|
Config,
|
||||||
|
MethodFn,
|
||||||
|
BuildUrlFn,
|
||||||
|
SseFn
|
||||||
|
> & {
|
||||||
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
* and the returned object will become the client's initial configuration.
|
||||||
|
*
|
||||||
|
* You may want to initialize your client this way instead of calling
|
||||||
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
|
* to ensure your client always has the correct values.
|
||||||
|
*/
|
||||||
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
|
export interface TDataShape {
|
||||||
|
body?: unknown;
|
||||||
|
headers?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
query?: unknown;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
|
export type Options<
|
||||||
|
TData extends TDataShape = TDataShape,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponse = unknown,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = OmitKeys<
|
||||||
|
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'path' | 'query' | 'url'
|
||||||
|
> &
|
||||||
|
Omit<TData, 'url'>;
|
||||||
|
|
||||||
|
export type OptionsLegacyParser<
|
||||||
|
TData = unknown,
|
||||||
|
ThrowOnError extends boolean = boolean,
|
||||||
|
TResponseStyle extends ResponseStyle = 'fields',
|
||||||
|
> = TData extends { body?: any }
|
||||||
|
? TData extends { headers?: any }
|
||||||
|
? OmitKeys<
|
||||||
|
RequestOptions<unknown, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'headers' | 'url'
|
||||||
|
> &
|
||||||
|
TData
|
||||||
|
: OmitKeys<
|
||||||
|
RequestOptions<unknown, TResponseStyle, ThrowOnError>,
|
||||||
|
'body' | 'url'
|
||||||
|
> &
|
||||||
|
TData &
|
||||||
|
Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'headers'>
|
||||||
|
: TData extends { headers?: any }
|
||||||
|
? OmitKeys<
|
||||||
|
RequestOptions<unknown, TResponseStyle, ThrowOnError>,
|
||||||
|
'headers' | 'url'
|
||||||
|
> &
|
||||||
|
TData &
|
||||||
|
Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'body'>
|
||||||
|
: OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, 'url'> &
|
||||||
|
TData;
|
||||||
331
frontend/src/api/client/utils.gen.ts
Normal file
331
frontend/src/api/client/utils.gen.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import { getAuthToken } from '../core/auth.gen';
|
||||||
|
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||||
|
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from '../core/pathSerializer.gen';
|
||||||
|
import { getUrl } from '../core/utils.gen';
|
||||||
|
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||||
|
|
||||||
|
export const createQuerySerializer = <T = unknown>({
|
||||||
|
allowReserved,
|
||||||
|
array,
|
||||||
|
object,
|
||||||
|
}: QuerySerializerOptions = {}) => {
|
||||||
|
const querySerializer = (queryParams: T) => {
|
||||||
|
const search: string[] = [];
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
for (const name in queryParams) {
|
||||||
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const serializedArray = serializeArrayParam({
|
||||||
|
allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'form',
|
||||||
|
value,
|
||||||
|
...array,
|
||||||
|
});
|
||||||
|
if (serializedArray) search.push(serializedArray);
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
const serializedObject = serializeObjectParam({
|
||||||
|
allowReserved,
|
||||||
|
explode: true,
|
||||||
|
name,
|
||||||
|
style: 'deepObject',
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
...object,
|
||||||
|
});
|
||||||
|
if (serializedObject) search.push(serializedObject);
|
||||||
|
} else {
|
||||||
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
});
|
||||||
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return search.join('&');
|
||||||
|
};
|
||||||
|
return querySerializer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers parseAs value from provided Content-Type header.
|
||||||
|
*/
|
||||||
|
export const getParseAs = (
|
||||||
|
contentType: string | null,
|
||||||
|
): Exclude<Config['parseAs'], 'auto'> => {
|
||||||
|
if (!contentType) {
|
||||||
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
|
// which is effectively the same as the 'stream' option.
|
||||||
|
return 'stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanContent = contentType.split(';')[0]?.trim();
|
||||||
|
|
||||||
|
if (!cleanContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleanContent.startsWith('application/json') ||
|
||||||
|
cleanContent.endsWith('+json')
|
||||||
|
) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent === 'multipart/form-data') {
|
||||||
|
return 'formData';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||||
|
cleanContent.startsWith(type),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanContent.startsWith('text/')) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkForExistence = (
|
||||||
|
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Headers;
|
||||||
|
},
|
||||||
|
name?: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.headers.has(name) ||
|
||||||
|
options.query?.[name] ||
|
||||||
|
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAuthParams = async ({
|
||||||
|
security,
|
||||||
|
...options
|
||||||
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
|
headers: Headers;
|
||||||
|
}) => {
|
||||||
|
for (const auth of security) {
|
||||||
|
if (checkForExistence(options, auth.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = auth.name ?? 'Authorization';
|
||||||
|
|
||||||
|
switch (auth.in) {
|
||||||
|
case 'query':
|
||||||
|
if (!options.query) {
|
||||||
|
options.query = {};
|
||||||
|
}
|
||||||
|
options.query[name] = token;
|
||||||
|
break;
|
||||||
|
case 'cookie':
|
||||||
|
options.headers.append('Cookie', `${name}=${token}`);
|
||||||
|
break;
|
||||||
|
case 'header':
|
||||||
|
default:
|
||||||
|
options.headers.set(name, token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
|
getUrl({
|
||||||
|
baseUrl: options.baseUrl as string,
|
||||||
|
path: options.path,
|
||||||
|
query: options.query,
|
||||||
|
querySerializer:
|
||||||
|
typeof options.querySerializer === 'function'
|
||||||
|
? options.querySerializer
|
||||||
|
: createQuerySerializer(options.querySerializer),
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
|
const config = { ...a, ...b };
|
||||||
|
if (config.baseUrl?.endsWith('/')) {
|
||||||
|
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
|
const entries: Array<[string, string]> = [];
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
entries.push([key, value]);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeHeaders = (
|
||||||
|
...headers: Array<Required<Config>['headers'] | undefined>
|
||||||
|
): Headers => {
|
||||||
|
const mergedHeaders = new Headers();
|
||||||
|
for (const header of headers) {
|
||||||
|
if (!header) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterator =
|
||||||
|
header instanceof Headers
|
||||||
|
? headersEntries(header)
|
||||||
|
: Object.entries(header);
|
||||||
|
|
||||||
|
for (const [key, value] of iterator) {
|
||||||
|
if (value === null) {
|
||||||
|
mergedHeaders.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
mergedHeaders.append(key, v as string);
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
|
// content value in OpenAPI specification is 'application/json'
|
||||||
|
mergedHeaders.set(
|
||||||
|
key,
|
||||||
|
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedHeaders;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
|
error: Err,
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
|
type ReqInterceptor<Req, Options> = (
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Req | Promise<Req>;
|
||||||
|
|
||||||
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
|
response: Res,
|
||||||
|
request: Req,
|
||||||
|
options: Options,
|
||||||
|
) => Res | Promise<Res>;
|
||||||
|
|
||||||
|
class Interceptors<Interceptor> {
|
||||||
|
fns: Array<Interceptor | null> = [];
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.fns = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
eject(id: number | Interceptor): void {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exists(id: number | Interceptor): boolean {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
return Boolean(this.fns[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInterceptorIndex(id: number | Interceptor): number {
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
return this.fns[id] ? id : -1;
|
||||||
|
}
|
||||||
|
return this.fns.indexOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number | Interceptor,
|
||||||
|
fn: Interceptor,
|
||||||
|
): number | Interceptor | false {
|
||||||
|
const index = this.getInterceptorIndex(id);
|
||||||
|
if (this.fns[index]) {
|
||||||
|
this.fns[index] = fn;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(fn: Interceptor): number {
|
||||||
|
this.fns.push(fn);
|
||||||
|
return this.fns.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Middleware<Req, Res, Err, Options> {
|
||||||
|
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||||
|
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||||
|
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
Err,
|
||||||
|
Options
|
||||||
|
> => ({
|
||||||
|
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||||
|
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||||
|
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultQuerySerializer = createQuerySerializer({
|
||||||
|
allowReserved: false,
|
||||||
|
array: {
|
||||||
|
explode: true,
|
||||||
|
style: 'form',
|
||||||
|
},
|
||||||
|
object: {
|
||||||
|
explode: true,
|
||||||
|
style: 'deepObject',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
|
...jsonBodySerializer,
|
||||||
|
headers: defaultHeaders,
|
||||||
|
parseAs: 'auto',
|
||||||
|
querySerializer: defaultQuerySerializer,
|
||||||
|
...override,
|
||||||
|
});
|
||||||
42
frontend/src/api/core/auth.gen.ts
Normal file
42
frontend/src/api/core/auth.gen.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/**
|
||||||
|
* Which part of the request do we use to send the auth?
|
||||||
|
*
|
||||||
|
* @default 'header'
|
||||||
|
*/
|
||||||
|
in?: 'header' | 'query' | 'cookie';
|
||||||
|
/**
|
||||||
|
* Header or query parameter name.
|
||||||
|
*
|
||||||
|
* @default 'Authorization'
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
scheme?: 'basic' | 'bearer';
|
||||||
|
type: 'apiKey' | 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthToken = async (
|
||||||
|
auth: Auth,
|
||||||
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const token =
|
||||||
|
typeof callback === 'function' ? await callback(auth) : callback;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'bearer') {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.scheme === 'basic') {
|
||||||
|
return `Basic ${btoa(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
92
frontend/src/api/core/bodySerializer.gen.ts
Normal file
92
frontend/src/api/core/bodySerializer.gen.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ArrayStyle,
|
||||||
|
ObjectStyle,
|
||||||
|
SerializerOptions,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type BodySerializer = (body: any) => any;
|
||||||
|
|
||||||
|
export interface QuerySerializerOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
array?: SerializerOptions<ArrayStyle>;
|
||||||
|
object?: SerializerOptions<ObjectStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeFormDataPair = (
|
||||||
|
data: FormData,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
|
if (typeof value === 'string' || value instanceof Blob) {
|
||||||
|
data.append(key, value);
|
||||||
|
} else if (value instanceof Date) {
|
||||||
|
data.append(key, value.toISOString());
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeUrlSearchParamsPair = (
|
||||||
|
data: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
data.append(key, value);
|
||||||
|
} else {
|
||||||
|
data.append(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formDataBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeFormDataPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonBodySerializer = {
|
||||||
|
bodySerializer: <T>(body: T): string =>
|
||||||
|
JSON.stringify(body, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const urlSearchParamsBodySerializer = {
|
||||||
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
|
body: T,
|
||||||
|
): string => {
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
|
} else {
|
||||||
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.toString();
|
||||||
|
},
|
||||||
|
};
|
||||||
153
frontend/src/api/core/params.gen.ts
Normal file
153
frontend/src/api/core/params.gen.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||||
|
|
||||||
|
export type Field =
|
||||||
|
| {
|
||||||
|
in: Exclude<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Field name. This is the name we want the user to see and use.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
|
* If omitted, we use the same value as `key`.
|
||||||
|
*/
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
in: Extract<Slot, 'body'>;
|
||||||
|
/**
|
||||||
|
* Key isn't required for bodies.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
map?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Fields {
|
||||||
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
|
args?: ReadonlyArray<Field>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
|
$body_: 'body',
|
||||||
|
$headers_: 'headers',
|
||||||
|
$path_: 'path',
|
||||||
|
$query_: 'query',
|
||||||
|
};
|
||||||
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
type KeyMap = Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
in: Slot;
|
||||||
|
map?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of fields) {
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
map.set(config.key, {
|
||||||
|
in: config.in,
|
||||||
|
map: config.map,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (config.args) {
|
||||||
|
buildKeyMap(config.args, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Params {
|
||||||
|
body: unknown;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripEmptySlots = (params: Params) => {
|
||||||
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
|
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||||
|
delete params[slot as Slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildClientParams = (
|
||||||
|
args: ReadonlyArray<unknown>,
|
||||||
|
fields: FieldsConfig,
|
||||||
|
) => {
|
||||||
|
const params: Params = {
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
path: {},
|
||||||
|
query: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
|
for (const [index, arg] of args.entries()) {
|
||||||
|
if (fields[index]) {
|
||||||
|
config = fields[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('in' in config) {
|
||||||
|
if (config.key) {
|
||||||
|
const field = map.get(config.key)!;
|
||||||
|
const name = field.map || config.key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
|
} else {
|
||||||
|
params.body = arg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
|
const field = map.get(key);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
const name = field.map || key;
|
||||||
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
|
} else {
|
||||||
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
|
key.startsWith(prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extra) {
|
||||||
|
const [prefix, slot] = extra;
|
||||||
|
(params[slot] as Record<string, unknown>)[
|
||||||
|
key.slice(prefix.length)
|
||||||
|
] = value;
|
||||||
|
} else {
|
||||||
|
for (const [slot, allowed] of Object.entries(
|
||||||
|
config.allowExtra ?? {},
|
||||||
|
)) {
|
||||||
|
if (allowed) {
|
||||||
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stripEmptySlots(params);
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
181
frontend/src/api/core/pathSerializer.gen.ts
Normal file
181
frontend/src/api/core/pathSerializer.gen.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
interface SerializeOptions<T>
|
||||||
|
extends SerializePrimitiveOptions,
|
||||||
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
|
interface SerializePrimitiveOptions {
|
||||||
|
allowReserved?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializerOptions<T> {
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
explode: boolean;
|
||||||
|
style: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||||
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
|
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||||
|
export type ObjectStyle = 'form' | 'deepObject';
|
||||||
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return ',';
|
||||||
|
case 'pipeDelimited':
|
||||||
|
return '|';
|
||||||
|
case 'spaceDelimited':
|
||||||
|
return '%20';
|
||||||
|
default:
|
||||||
|
return ',';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return '.';
|
||||||
|
case 'matrix':
|
||||||
|
return ';';
|
||||||
|
case 'simple':
|
||||||
|
return ',';
|
||||||
|
default:
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeArrayParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
|
value: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!explode) {
|
||||||
|
const joinedValues = (
|
||||||
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
|
).join(separatorArrayNoExplode(style));
|
||||||
|
switch (style) {
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
case 'simple':
|
||||||
|
return joinedValues;
|
||||||
|
default:
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorArrayExplode(style);
|
||||||
|
const joinedValues = value
|
||||||
|
.map((v) => {
|
||||||
|
if (style === 'label' || style === 'simple') {
|
||||||
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value: v as string,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializePrimitiveParam = ({
|
||||||
|
allowReserved,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
}: SerializePrimitiveParam) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeObjectParam = ({
|
||||||
|
allowReserved,
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
valueOnly,
|
||||||
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
|
value: Record<string, unknown> | Date;
|
||||||
|
valueOnly?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style !== 'deepObject' && !explode) {
|
||||||
|
let values: string[] = [];
|
||||||
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
|
values = [
|
||||||
|
...values,
|
||||||
|
key,
|
||||||
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const joinedValues = values.join(',');
|
||||||
|
switch (style) {
|
||||||
|
case 'form':
|
||||||
|
return `${name}=${joinedValues}`;
|
||||||
|
case 'label':
|
||||||
|
return `.${joinedValues}`;
|
||||||
|
case 'matrix':
|
||||||
|
return `;${name}=${joinedValues}`;
|
||||||
|
default:
|
||||||
|
return joinedValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = separatorObjectExplode(style);
|
||||||
|
const joinedValues = Object.entries(value)
|
||||||
|
.map(([key, v]) =>
|
||||||
|
serializePrimitiveParam({
|
||||||
|
allowReserved,
|
||||||
|
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||||
|
value: v as string,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join(separator);
|
||||||
|
return style === 'label' || style === 'matrix'
|
||||||
|
? separator + joinedValues
|
||||||
|
: joinedValues;
|
||||||
|
};
|
||||||
264
frontend/src/api/core/serverSentEvents.gen.ts
Normal file
264
frontend/src/api/core/serverSentEvents.gen.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Config } from './types.gen';
|
||||||
|
|
||||||
|
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
|
RequestInit,
|
||||||
|
'method'
|
||||||
|
> &
|
||||||
|
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||||
|
/**
|
||||||
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
|
* fetch instance.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
|
*/
|
||||||
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
|
/**
|
||||||
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param error The error that occurred.
|
||||||
|
*/
|
||||||
|
onSseError?: (error: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Callback invoked when an event is streamed from the server.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @param event Event streamed from the server.
|
||||||
|
* @returns Nothing (void).
|
||||||
|
*/
|
||||||
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
|
serializedBody?: RequestInit['body'];
|
||||||
|
/**
|
||||||
|
* Default retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
sseDefaultRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Maximum number of retry attempts before giving up.
|
||||||
|
*/
|
||||||
|
sseMaxRetryAttempts?: number;
|
||||||
|
/**
|
||||||
|
* Maximum retry delay in milliseconds.
|
||||||
|
*
|
||||||
|
* Applies only when exponential backoff is used.
|
||||||
|
*
|
||||||
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
|
*
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
sseMaxRetryDelay?: number;
|
||||||
|
/**
|
||||||
|
* Optional sleep function for retry backoff.
|
||||||
|
*
|
||||||
|
* Defaults to using `setTimeout`.
|
||||||
|
*/
|
||||||
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StreamEvent<TData = unknown> {
|
||||||
|
data: TData;
|
||||||
|
event?: string;
|
||||||
|
id?: string;
|
||||||
|
retry?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerSentEventsResult<
|
||||||
|
TData = unknown,
|
||||||
|
TReturn = void,
|
||||||
|
TNext = unknown,
|
||||||
|
> = {
|
||||||
|
stream: AsyncGenerator<
|
||||||
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
|
TReturn,
|
||||||
|
TNext
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSseClient = <TData = unknown>({
|
||||||
|
onRequest,
|
||||||
|
onSseError,
|
||||||
|
onSseEvent,
|
||||||
|
responseTransformer,
|
||||||
|
responseValidator,
|
||||||
|
sseDefaultRetryDelay,
|
||||||
|
sseMaxRetryAttempts,
|
||||||
|
sseMaxRetryDelay,
|
||||||
|
sseSleepFn,
|
||||||
|
url,
|
||||||
|
...options
|
||||||
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||||
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
|
const sleep =
|
||||||
|
sseSleepFn ??
|
||||||
|
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
|
const createStream = async function* () {
|
||||||
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
|
let attempt = 0;
|
||||||
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
const headers =
|
||||||
|
options.headers instanceof Headers
|
||||||
|
? options.headers
|
||||||
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
|
if (lastEventId !== undefined) {
|
||||||
|
headers.set('Last-Event-ID', lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
redirect: 'follow',
|
||||||
|
...options,
|
||||||
|
body: options.serializedBody,
|
||||||
|
headers,
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
let request = new Request(url, requestInit);
|
||||||
|
if (onRequest) {
|
||||||
|
request = await onRequest(url, requestInit);
|
||||||
|
}
|
||||||
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await _fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
`SSE failed: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.body) throw new Error('No body in SSE response');
|
||||||
|
|
||||||
|
const reader = response.body
|
||||||
|
.pipeThrough(new TextDecoderStream())
|
||||||
|
.getReader();
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
try {
|
||||||
|
reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += value;
|
||||||
|
|
||||||
|
const chunks = buffer.split('\n\n');
|
||||||
|
buffer = chunks.pop() ?? '';
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
const dataLines: Array<string> = [];
|
||||||
|
let eventName: string | undefined;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
eventName = line.replace(/^event:\s*/, '');
|
||||||
|
} else if (line.startsWith('id:')) {
|
||||||
|
lastEventId = line.replace(/^id:\s*/, '');
|
||||||
|
} else if (line.startsWith('retry:')) {
|
||||||
|
const parsed = Number.parseInt(
|
||||||
|
line.replace(/^retry:\s*/, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
retryDelay = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
let parsedJson = false;
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
const rawData = dataLines.join('\n');
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData);
|
||||||
|
parsedJson = true;
|
||||||
|
} catch {
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedJson) {
|
||||||
|
if (responseValidator) {
|
||||||
|
await responseValidator(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseTransformer) {
|
||||||
|
data = await responseTransformer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSseEvent?.({
|
||||||
|
data,
|
||||||
|
event: eventName,
|
||||||
|
id: lastEventId,
|
||||||
|
retry: retryDelay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dataLines.length) {
|
||||||
|
yield data as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // exit loop on normal completion
|
||||||
|
} catch (error) {
|
||||||
|
// connection failed or aborted; retry after delay
|
||||||
|
onSseError?.(error);
|
||||||
|
|
||||||
|
if (
|
||||||
|
sseMaxRetryAttempts !== undefined &&
|
||||||
|
attempt >= sseMaxRetryAttempts
|
||||||
|
) {
|
||||||
|
break; // stop after firing error
|
||||||
|
}
|
||||||
|
|
||||||
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
|
const backoff = Math.min(
|
||||||
|
retryDelay * 2 ** (attempt - 1),
|
||||||
|
sseMaxRetryDelay ?? 30000,
|
||||||
|
);
|
||||||
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = createStream();
|
||||||
|
|
||||||
|
return { stream };
|
||||||
|
};
|
||||||
118
frontend/src/api/core/types.gen.ts
Normal file
118
frontend/src/api/core/types.gen.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Auth, AuthToken } from './auth.gen';
|
||||||
|
import type {
|
||||||
|
BodySerializer,
|
||||||
|
QuerySerializer,
|
||||||
|
QuerySerializerOptions,
|
||||||
|
} from './bodySerializer.gen';
|
||||||
|
|
||||||
|
export type HttpMethod =
|
||||||
|
| 'connect'
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'head'
|
||||||
|
| 'options'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'trace';
|
||||||
|
|
||||||
|
export type Client<
|
||||||
|
RequestFn = never,
|
||||||
|
Config = unknown,
|
||||||
|
MethodFn = never,
|
||||||
|
BuildUrlFn = never,
|
||||||
|
SseFn = never,
|
||||||
|
> = {
|
||||||
|
/**
|
||||||
|
* Returns the final request URL.
|
||||||
|
*/
|
||||||
|
buildUrl: BuildUrlFn;
|
||||||
|
getConfig: () => Config;
|
||||||
|
request: RequestFn;
|
||||||
|
setConfig: (config: Config) => Config;
|
||||||
|
} & {
|
||||||
|
[K in HttpMethod]: MethodFn;
|
||||||
|
} & ([SseFn] extends [never]
|
||||||
|
? { sse?: never }
|
||||||
|
: { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
/**
|
||||||
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
|
* added to the request payload as defined by its `security` array.
|
||||||
|
*/
|
||||||
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
|
/**
|
||||||
|
* A function for serializing request body parameter. By default,
|
||||||
|
* {@link JSON.stringify()} will be used.
|
||||||
|
*/
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
/**
|
||||||
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
|
* `Headers` object with.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
|
*/
|
||||||
|
headers?:
|
||||||
|
| RequestInit['headers']
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| (string | number | boolean)[]
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| unknown
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The request method.
|
||||||
|
*
|
||||||
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
|
*/
|
||||||
|
method?: Uppercase<HttpMethod>;
|
||||||
|
/**
|
||||||
|
* A function for serializing request query parameters. By default, arrays
|
||||||
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
|
* style, and reserved characters are percent-encoded.
|
||||||
|
*
|
||||||
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
|
* API function is used.
|
||||||
|
*
|
||||||
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
|
*/
|
||||||
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
|
/**
|
||||||
|
* A function validating request data. This is useful if you want to ensure
|
||||||
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function transforming response data before it's returned. This is useful
|
||||||
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
|
*/
|
||||||
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* A function validating response data. This is useful if you want to ensure
|
||||||
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
|
* the transformers and returned to the user.
|
||||||
|
*/
|
||||||
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
|
? true
|
||||||
|
: [T] extends [never | undefined]
|
||||||
|
? [undefined] extends [T]
|
||||||
|
? false
|
||||||
|
: true
|
||||||
|
: false;
|
||||||
|
|
||||||
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||||
|
? never
|
||||||
|
: K]: T[K];
|
||||||
|
};
|
||||||
143
frontend/src/api/core/utils.gen.ts
Normal file
143
frontend/src/api/core/utils.gen.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||||
|
import {
|
||||||
|
type ArraySeparatorStyle,
|
||||||
|
serializeArrayParam,
|
||||||
|
serializeObjectParam,
|
||||||
|
serializePrimitiveParam,
|
||||||
|
} from './pathSerializer.gen';
|
||||||
|
|
||||||
|
export interface PathSerializer {
|
||||||
|
path: Record<string, unknown>;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
|
let url = _url;
|
||||||
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
|
if (matches) {
|
||||||
|
for (const match of matches) {
|
||||||
|
let explode = false;
|
||||||
|
let name = match.substring(1, match.length - 1);
|
||||||
|
let style: ArraySeparatorStyle = 'simple';
|
||||||
|
|
||||||
|
if (name.endsWith('*')) {
|
||||||
|
explode = true;
|
||||||
|
name = name.substring(0, name.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith('.')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'label';
|
||||||
|
} else if (name.startsWith(';')) {
|
||||||
|
name = name.substring(1);
|
||||||
|
style = 'matrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = path[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
serializeObjectParam({
|
||||||
|
explode,
|
||||||
|
name,
|
||||||
|
style,
|
||||||
|
value: value as Record<string, unknown>,
|
||||||
|
valueOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'matrix') {
|
||||||
|
url = url.replace(
|
||||||
|
match,
|
||||||
|
`;${serializePrimitiveParam({
|
||||||
|
name,
|
||||||
|
value: value as string,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceValue = encodeURIComponent(
|
||||||
|
style === 'label' ? `.${value as string}` : (value as string),
|
||||||
|
);
|
||||||
|
url = url.replace(match, replaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUrl = ({
|
||||||
|
baseUrl,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
querySerializer,
|
||||||
|
url: _url,
|
||||||
|
}: {
|
||||||
|
baseUrl?: string;
|
||||||
|
path?: Record<string, unknown>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
querySerializer: QuerySerializer;
|
||||||
|
url: string;
|
||||||
|
}) => {
|
||||||
|
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||||
|
let url = (baseUrl ?? '') + pathUrl;
|
||||||
|
if (path) {
|
||||||
|
url = defaultPathSerializer({ path, url });
|
||||||
|
}
|
||||||
|
let search = query ? querySerializer(query) : '';
|
||||||
|
if (search.startsWith('?')) {
|
||||||
|
search = search.substring(1);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
url += `?${search}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getValidRequestBody(options: {
|
||||||
|
body?: unknown;
|
||||||
|
bodySerializer?: BodySerializer | null;
|
||||||
|
serializedBody?: unknown;
|
||||||
|
}) {
|
||||||
|
const hasBody = options.body !== undefined;
|
||||||
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
|
if (isSerializedBody) {
|
||||||
|
if ('serializedBody' in options) {
|
||||||
|
const hasSerializedBody =
|
||||||
|
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||||
|
|
||||||
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||||
|
return options.body !== '' ? options.body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plain/text body
|
||||||
|
if (hasBody) {
|
||||||
|
return options.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no body was provided
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type * from './types.gen';
|
||||||
|
export * from './sdk.gen';
|
||||||
452
frontend/src/api/sdk.gen.ts
Normal file
452
frontend/src/api/sdk.gen.ts
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
|
import { client } from './client.gen';
|
||||||
|
import type { AccountsApiLoginData, AccountsApiLoginResponses, AccountsApiLogoutData, AccountsApiLogoutResponses, AccountsApiMeData, AccountsApiMeResponses, OsirisApiHealthData, OsirisApiHealthResponses, TrackerApiDeletePulseData, TrackerApiDeletePulseResponses, TrackerApiGetDayData, TrackerApiGetDayResponses, TrackerApiGetGlobalNoteData, TrackerApiGetGlobalNoteResponses, TrackerApiGetRecapData, TrackerApiGetRecapResponses, TrackerApiListDailyNotesData, TrackerApiListDailyNotesResponses, TrackerApiListMedicationsData, TrackerApiListMedicationsResponses, TrackerApiListPulseData, TrackerApiListPulseResponses, TrackerApiLogDoseData, TrackerApiLogDoseResponses, TrackerApiPulseStatsData, TrackerApiPulseStatsResponses, TrackerApiRecordPulseData, TrackerApiRecordPulseResponses, TrackerApiSaveDailyNoteData, TrackerApiSaveDailyNoteResponses, TrackerApiSaveGlobalNoteData, TrackerApiSaveGlobalNoteResponses, TrackerPushApiPublicKeyData, TrackerPushApiPublicKeyResponses, TrackerPushApiSendTestData, TrackerPushApiSendTestResponses, TrackerPushApiSubscribeData, TrackerPushApiSubscribeResponses, TrackerPushApiUnsubscribeData, TrackerPushApiUnsubscribeResponses } from './types.gen';
|
||||||
|
|
||||||
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
|
/**
|
||||||
|
* You can provide a client instance returned by `createClient()` instead of
|
||||||
|
* individual options. This might be also useful if you want to implement a
|
||||||
|
* custom client.
|
||||||
|
*/
|
||||||
|
client?: Client;
|
||||||
|
/**
|
||||||
|
* You can pass arbitrary values through the `meta` object. This can be
|
||||||
|
* used to access values that aren't defined as part of the SDK function.
|
||||||
|
*/
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health
|
||||||
|
*/
|
||||||
|
export const osirisApiHealth = <ThrowOnError extends boolean = false>(options?: Options<OsirisApiHealthData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<OsirisApiHealthResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/health',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login
|
||||||
|
* Exchange username/password for a bearer token. There is no registration.
|
||||||
|
*/
|
||||||
|
export const accountsApiLogin = <ThrowOnError extends boolean = false>(options: Options<AccountsApiLoginData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<AccountsApiLoginResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/auth/login',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
* Revoke the token used for this request. Session logins are unaffected.
|
||||||
|
*/
|
||||||
|
export const accountsApiLogout = <ThrowOnError extends boolean = false>(options?: Options<AccountsApiLogoutData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<AccountsApiLogoutResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/auth/logout',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Me
|
||||||
|
*/
|
||||||
|
export const accountsApiMe = <ThrowOnError extends boolean = false>(options?: Options<AccountsApiMeData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<AccountsApiMeResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/auth/me',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Medications
|
||||||
|
* The medication plan. Edit it in the Django admin.
|
||||||
|
*/
|
||||||
|
export const trackerApiListMedications = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiListMedicationsData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiListMedicationsResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/medications',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Day
|
||||||
|
* Everything for one day (defaults to today): the dose checklist and the pulse.
|
||||||
|
*/
|
||||||
|
export const trackerApiGetDay = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiGetDayData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiGetDayResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/day',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Recap
|
||||||
|
* Day-by-day recap, newest first: were all doses given, pulse and note.
|
||||||
|
*/
|
||||||
|
export const trackerApiGetRecap = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiGetRecapData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiGetRecapResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/recap',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log Dose
|
||||||
|
* Tick a dose off (or untick it). Idempotent per (dose, day).
|
||||||
|
*/
|
||||||
|
export const trackerApiLogDose = <ThrowOnError extends boolean = false>(options: Options<TrackerApiLogDoseData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TrackerApiLogDoseResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/doses',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Daily Notes
|
||||||
|
* The whole journal, newest first.
|
||||||
|
*/
|
||||||
|
export const trackerApiListDailyNotes = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiListDailyNotesData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiListDailyNotesResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/notes/daily',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Daily Note
|
||||||
|
* The day's journal entry. One per day; an empty body removes it.
|
||||||
|
*/
|
||||||
|
export const trackerApiSaveDailyNote = <ThrowOnError extends boolean = false>(options: Options<TrackerApiSaveDailyNoteData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TrackerApiSaveDailyNoteResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/notes/daily',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Global Note
|
||||||
|
* The single shared note: standing instructions and things to do.
|
||||||
|
*/
|
||||||
|
export const trackerApiGetGlobalNote = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiGetGlobalNoteData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiGetGlobalNoteResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/notes/global',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save Global Note
|
||||||
|
* Replace the shared note's content. Last write wins.
|
||||||
|
*/
|
||||||
|
export const trackerApiSaveGlobalNote = <ThrowOnError extends boolean = false>(options: Options<TrackerApiSaveGlobalNoteData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).put<TrackerApiSaveGlobalNoteResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/notes/global',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Pulse
|
||||||
|
* Readings for the graph, oldest first.
|
||||||
|
*/
|
||||||
|
export const trackerApiListPulse = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiListPulseData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiListPulseResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/pulse',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record Pulse
|
||||||
|
* One reading per day: posting twice for the same day overwrites it.
|
||||||
|
*/
|
||||||
|
export const trackerApiRecordPulse = <ThrowOnError extends boolean = false>(options: Options<TrackerApiRecordPulseData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TrackerApiRecordPulseResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/pulse',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulse Stats
|
||||||
|
* Is the pulse steady or drifting? `trend_bpm_per_day` is the slope.
|
||||||
|
*
|
||||||
|
* Declared before /pulse/{date}, which would otherwise match "stats" first.
|
||||||
|
*/
|
||||||
|
export const trackerApiPulseStats = <ThrowOnError extends boolean = false>(options?: Options<TrackerApiPulseStatsData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerApiPulseStatsResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/pulse/stats',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Pulse
|
||||||
|
*/
|
||||||
|
export const trackerApiDeletePulse = <ThrowOnError extends boolean = false>(options: Options<TrackerApiDeletePulseData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).delete<TrackerApiDeletePulseResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/tracker/pulse/{date}',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe
|
||||||
|
* Register this device for reminders. Re-subscribing the same endpoint is a no-op.
|
||||||
|
*/
|
||||||
|
export const trackerPushApiSubscribe = <ThrowOnError extends boolean = false>(options: Options<TrackerPushApiSubscribeData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TrackerPushApiSubscribeResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/push/subscribe',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe
|
||||||
|
*/
|
||||||
|
export const trackerPushApiUnsubscribe = <ThrowOnError extends boolean = false>(options: Options<TrackerPushApiUnsubscribeData, ThrowOnError>) => {
|
||||||
|
return (options.client ?? client).post<TrackerPushApiUnsubscribeResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/push/unsubscribe',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Test
|
||||||
|
* Push a notification right now, to prove the chain works end to end.
|
||||||
|
*/
|
||||||
|
export const trackerPushApiSendTest = <ThrowOnError extends boolean = false>(options?: Options<TrackerPushApiSendTestData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).post<TrackerPushApiSendTestResponses, unknown, ThrowOnError>({
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
scheme: 'bearer',
|
||||||
|
type: 'http'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'sessionid',
|
||||||
|
type: 'apiKey'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
url: '/api/push/test',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key
|
||||||
|
* The applicationServerKey the browser needs in order to subscribe.
|
||||||
|
*/
|
||||||
|
export const trackerPushApiPublicKey = <ThrowOnError extends boolean = false>(options?: Options<TrackerPushApiPublicKeyData, ThrowOnError>) => {
|
||||||
|
return (options?.client ?? client).get<TrackerPushApiPublicKeyResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/api/push/public-key',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
713
frontend/src/api/types.gen.ts
Normal file
713
frontend/src/api/types.gen.ts
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
|
export type ClientOptions = {
|
||||||
|
baseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TokenOut
|
||||||
|
*/
|
||||||
|
export type TokenOut = {
|
||||||
|
/**
|
||||||
|
* Token
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
/**
|
||||||
|
* Username
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoginIn
|
||||||
|
*/
|
||||||
|
export type LoginIn = {
|
||||||
|
/**
|
||||||
|
* Username
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
/**
|
||||||
|
* Password
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
/**
|
||||||
|
* Device Name
|
||||||
|
*/
|
||||||
|
device_name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserOut
|
||||||
|
*/
|
||||||
|
export type UserOut = {
|
||||||
|
/**
|
||||||
|
* Username
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
/**
|
||||||
|
* Is Staff
|
||||||
|
*/
|
||||||
|
is_staff: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MedicationOut
|
||||||
|
*/
|
||||||
|
export type MedicationOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Name
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Is Active
|
||||||
|
*/
|
||||||
|
is_active: boolean;
|
||||||
|
/**
|
||||||
|
* Notes
|
||||||
|
*/
|
||||||
|
notes: string;
|
||||||
|
/**
|
||||||
|
* Scheduled Doses
|
||||||
|
*/
|
||||||
|
scheduled_doses: Array<ScheduledDoseOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScheduledDoseOut
|
||||||
|
*/
|
||||||
|
export type ScheduledDoseOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Medication Id
|
||||||
|
*/
|
||||||
|
medication_id: number;
|
||||||
|
/**
|
||||||
|
* Medication Name
|
||||||
|
*/
|
||||||
|
medication_name: string;
|
||||||
|
/**
|
||||||
|
* Amount
|
||||||
|
*/
|
||||||
|
amount: string;
|
||||||
|
/**
|
||||||
|
* Time Of Day
|
||||||
|
*/
|
||||||
|
time_of_day: number;
|
||||||
|
/**
|
||||||
|
* Time Of Day Label
|
||||||
|
*/
|
||||||
|
time_of_day_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyNoteOut
|
||||||
|
*/
|
||||||
|
export type DailyNoteOut = {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
/**
|
||||||
|
* Body
|
||||||
|
*/
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DayOut
|
||||||
|
*/
|
||||||
|
export type DayOut = {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
/**
|
||||||
|
* Doses
|
||||||
|
*/
|
||||||
|
doses: Array<DoseStatusOut>;
|
||||||
|
pulse: PulseOut | null;
|
||||||
|
note: DailyNoteOut | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DoseStatusOut
|
||||||
|
* A scheduled dose plus whether it was given on the day being looked at.
|
||||||
|
*/
|
||||||
|
export type DoseStatusOut = {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Medication Id
|
||||||
|
*/
|
||||||
|
medication_id: number;
|
||||||
|
/**
|
||||||
|
* Medication Name
|
||||||
|
*/
|
||||||
|
medication_name: string;
|
||||||
|
/**
|
||||||
|
* Amount
|
||||||
|
*/
|
||||||
|
amount: string;
|
||||||
|
/**
|
||||||
|
* Time Of Day
|
||||||
|
*/
|
||||||
|
time_of_day: number;
|
||||||
|
/**
|
||||||
|
* Time Of Day Label
|
||||||
|
*/
|
||||||
|
time_of_day_label: string;
|
||||||
|
/**
|
||||||
|
* Taken
|
||||||
|
*/
|
||||||
|
taken: boolean | null;
|
||||||
|
/**
|
||||||
|
* Notes
|
||||||
|
*/
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PulseOut
|
||||||
|
*/
|
||||||
|
export type PulseOut = {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
/**
|
||||||
|
* Bpm
|
||||||
|
*/
|
||||||
|
bpm: number;
|
||||||
|
/**
|
||||||
|
* Notes
|
||||||
|
*/
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecapDayOut
|
||||||
|
* One line of the recap: did every dose go out that day, and what was the pulse.
|
||||||
|
*/
|
||||||
|
export type RecapDayOut = {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
/**
|
||||||
|
* Taken
|
||||||
|
*/
|
||||||
|
taken: number;
|
||||||
|
/**
|
||||||
|
* Expected
|
||||||
|
*/
|
||||||
|
expected: number;
|
||||||
|
/**
|
||||||
|
* Complete
|
||||||
|
*/
|
||||||
|
complete: boolean;
|
||||||
|
/**
|
||||||
|
* Bpm
|
||||||
|
*/
|
||||||
|
bpm: number | null;
|
||||||
|
/**
|
||||||
|
* Note
|
||||||
|
*/
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DoseLogIn
|
||||||
|
*/
|
||||||
|
export type DoseLogIn = {
|
||||||
|
/**
|
||||||
|
* Scheduled Dose Id
|
||||||
|
*/
|
||||||
|
scheduled_dose_id: number;
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date?: string | null;
|
||||||
|
/**
|
||||||
|
* Taken
|
||||||
|
*/
|
||||||
|
taken?: boolean;
|
||||||
|
/**
|
||||||
|
* Notes
|
||||||
|
*/
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DailyNoteIn
|
||||||
|
*/
|
||||||
|
export type DailyNoteIn = {
|
||||||
|
/**
|
||||||
|
* Body
|
||||||
|
*/
|
||||||
|
body: string;
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalNoteOut
|
||||||
|
*/
|
||||||
|
export type GlobalNoteOut = {
|
||||||
|
/**
|
||||||
|
* Body
|
||||||
|
*/
|
||||||
|
body: string;
|
||||||
|
/**
|
||||||
|
* Updated At
|
||||||
|
*/
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalNoteIn
|
||||||
|
*/
|
||||||
|
export type GlobalNoteIn = {
|
||||||
|
/**
|
||||||
|
* Body
|
||||||
|
*/
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PulseIn
|
||||||
|
*/
|
||||||
|
export type PulseIn = {
|
||||||
|
/**
|
||||||
|
* Bpm
|
||||||
|
*/
|
||||||
|
bpm: number;
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date?: string | null;
|
||||||
|
/**
|
||||||
|
* Notes
|
||||||
|
*/
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PulseStatsOut
|
||||||
|
*/
|
||||||
|
export type PulseStatsOut = {
|
||||||
|
/**
|
||||||
|
* Count
|
||||||
|
*/
|
||||||
|
count: number;
|
||||||
|
/**
|
||||||
|
* Average
|
||||||
|
*/
|
||||||
|
average: number | null;
|
||||||
|
/**
|
||||||
|
* Minimum
|
||||||
|
*/
|
||||||
|
minimum: number | null;
|
||||||
|
/**
|
||||||
|
* Maximum
|
||||||
|
*/
|
||||||
|
maximum: number | null;
|
||||||
|
/**
|
||||||
|
* Trend Bpm Per Day
|
||||||
|
*/
|
||||||
|
trend_bpm_per_day: number | null;
|
||||||
|
/**
|
||||||
|
* First Date
|
||||||
|
*/
|
||||||
|
first_date: string | null;
|
||||||
|
/**
|
||||||
|
* Last Date
|
||||||
|
*/
|
||||||
|
last_date: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubscribeIn
|
||||||
|
* Exactly the shape of a browser PushSubscription, so the client can post it as-is.
|
||||||
|
*/
|
||||||
|
export type SubscribeIn = {
|
||||||
|
/**
|
||||||
|
* Endpoint
|
||||||
|
*/
|
||||||
|
endpoint: string;
|
||||||
|
keys: SubscriptionKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubscriptionKeys
|
||||||
|
*/
|
||||||
|
export type SubscriptionKeys = {
|
||||||
|
/**
|
||||||
|
* P256Dh
|
||||||
|
*/
|
||||||
|
p256dh: string;
|
||||||
|
/**
|
||||||
|
* Auth
|
||||||
|
*/
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnsubscribeIn
|
||||||
|
*/
|
||||||
|
export type UnsubscribeIn = {
|
||||||
|
/**
|
||||||
|
* Endpoint
|
||||||
|
*/
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OsirisApiHealthData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/health';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OsirisApiHealthResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiLoginData = {
|
||||||
|
body: LoginIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/auth/login';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiLoginResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: TokenOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiLoginResponse = AccountsApiLoginResponses[keyof AccountsApiLoginResponses];
|
||||||
|
|
||||||
|
export type AccountsApiLogoutData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/auth/logout';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiLogoutResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiMeData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/auth/me';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiMeResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: UserOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountsApiMeResponse = AccountsApiMeResponses[keyof AccountsApiMeResponses];
|
||||||
|
|
||||||
|
export type TrackerApiListMedicationsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Include Inactive
|
||||||
|
*/
|
||||||
|
include_inactive?: boolean;
|
||||||
|
};
|
||||||
|
url: '/api/tracker/medications';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListMedicationsResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<MedicationOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListMedicationsResponse = TrackerApiListMedicationsResponses[keyof TrackerApiListMedicationsResponses];
|
||||||
|
|
||||||
|
export type TrackerApiGetDayData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date?: string | null;
|
||||||
|
};
|
||||||
|
url: '/api/tracker/day';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetDayResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: DayOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetDayResponse = TrackerApiGetDayResponses[keyof TrackerApiGetDayResponses];
|
||||||
|
|
||||||
|
export type TrackerApiGetRecapData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Days
|
||||||
|
*/
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
url: '/api/tracker/recap';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetRecapResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<RecapDayOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetRecapResponse = TrackerApiGetRecapResponses[keyof TrackerApiGetRecapResponses];
|
||||||
|
|
||||||
|
export type TrackerApiLogDoseData = {
|
||||||
|
body: DoseLogIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/doses';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiLogDoseResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: DayOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiLogDoseResponse = TrackerApiLogDoseResponses[keyof TrackerApiLogDoseResponses];
|
||||||
|
|
||||||
|
export type TrackerApiListDailyNotesData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/notes/daily';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListDailyNotesResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<DailyNoteOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListDailyNotesResponse = TrackerApiListDailyNotesResponses[keyof TrackerApiListDailyNotesResponses];
|
||||||
|
|
||||||
|
export type TrackerApiSaveDailyNoteData = {
|
||||||
|
body: DailyNoteIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/notes/daily';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiSaveDailyNoteResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: DayOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiSaveDailyNoteResponse = TrackerApiSaveDailyNoteResponses[keyof TrackerApiSaveDailyNoteResponses];
|
||||||
|
|
||||||
|
export type TrackerApiGetGlobalNoteData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/notes/global';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetGlobalNoteResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: GlobalNoteOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiGetGlobalNoteResponse = TrackerApiGetGlobalNoteResponses[keyof TrackerApiGetGlobalNoteResponses];
|
||||||
|
|
||||||
|
export type TrackerApiSaveGlobalNoteData = {
|
||||||
|
body: GlobalNoteIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/notes/global';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiSaveGlobalNoteResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: GlobalNoteOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiSaveGlobalNoteResponse = TrackerApiSaveGlobalNoteResponses[keyof TrackerApiSaveGlobalNoteResponses];
|
||||||
|
|
||||||
|
export type TrackerApiListPulseData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Days
|
||||||
|
*/
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
url: '/api/tracker/pulse';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListPulseResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<PulseOut>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiListPulseResponse = TrackerApiListPulseResponses[keyof TrackerApiListPulseResponses];
|
||||||
|
|
||||||
|
export type TrackerApiRecordPulseData = {
|
||||||
|
body: PulseIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/pulse';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiRecordPulseResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: PulseOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiRecordPulseResponse = TrackerApiRecordPulseResponses[keyof TrackerApiRecordPulseResponses];
|
||||||
|
|
||||||
|
export type TrackerApiPulseStatsData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: {
|
||||||
|
/**
|
||||||
|
* Days
|
||||||
|
*/
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
url: '/api/tracker/pulse/stats';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiPulseStatsResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: PulseStatsOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiPulseStatsResponse = TrackerApiPulseStatsResponses[keyof TrackerApiPulseStatsResponses];
|
||||||
|
|
||||||
|
export type TrackerApiDeletePulseData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/tracker/pulse/{date}';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerApiDeletePulseResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiSubscribeData = {
|
||||||
|
body: SubscribeIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/push/subscribe';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiSubscribeResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiUnsubscribeData = {
|
||||||
|
body: UnsubscribeIn;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/push/unsubscribe';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiUnsubscribeResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiSendTestData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/push/test';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiSendTestResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiPublicKeyData = {
|
||||||
|
body?: never;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/api/push/public-key';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrackerPushApiPublicKeyResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: unknown;
|
||||||
|
};
|
||||||
85
frontend/src/components/PulseChart.vue
Normal file
85
frontend/src/components/PulseChart.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type { PulseOut } from "@/api";
|
||||||
|
import { formatDay } from "@/lib/dates";
|
||||||
|
|
||||||
|
const props = defineProps<{ readings: PulseOut[] }>();
|
||||||
|
|
||||||
|
const WIDTH = 320;
|
||||||
|
const HEIGHT = 140;
|
||||||
|
const PADDING = 24;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/** Readings laid out as SVG coordinates — same maths the Django view used. */
|
||||||
|
const chart = computed(() => {
|
||||||
|
const readings = props.readings;
|
||||||
|
if (readings.length < 2) return null;
|
||||||
|
|
||||||
|
const bpms = readings.map((r) => r.bpm);
|
||||||
|
// A flat line would divide by zero, and a narrow range exaggerates noise.
|
||||||
|
const span = Math.max(Math.max(...bpms) - Math.min(...bpms), 10);
|
||||||
|
const mid = (Math.max(...bpms) + Math.min(...bpms)) / 2;
|
||||||
|
const low = mid - span / 2;
|
||||||
|
const high = mid + span / 2;
|
||||||
|
|
||||||
|
const first = readings[0]!;
|
||||||
|
const last = readings[readings.length - 1]!;
|
||||||
|
const firstDay = Date.parse(first.date);
|
||||||
|
const totalDays = Math.max((Date.parse(last.date) - firstDay) / DAY_MS, 1);
|
||||||
|
|
||||||
|
const innerWidth = WIDTH - 2 * PADDING;
|
||||||
|
const innerHeight = HEIGHT - 2 * PADDING;
|
||||||
|
|
||||||
|
const points = readings.map((reading) => ({
|
||||||
|
x: PADDING + (((Date.parse(reading.date) - firstDay) / DAY_MS) / totalDays) * innerWidth,
|
||||||
|
y: PADDING + ((high - reading.bpm) / (high - low)) * innerHeight,
|
||||||
|
reading,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
polyline: points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "),
|
||||||
|
low: Math.round(low),
|
||||||
|
high: Math.round(high),
|
||||||
|
firstDate: first.date,
|
||||||
|
lastDate: last.date,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="chart"
|
||||||
|
class="w-full"
|
||||||
|
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
||||||
|
role="img"
|
||||||
|
:aria-label="`Pulse from ${formatDay(chart.firstDate)} to ${formatDay(chart.lastDate)}`"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
:x1="PADDING" :y1="PADDING" :x2="WIDTH - PADDING" :y2="PADDING"
|
||||||
|
stroke="currentColor" stroke-width="0.5" opacity="0.2"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
:x1="PADDING" :y1="HEIGHT - PADDING" :x2="WIDTH - PADDING" :y2="HEIGHT - PADDING"
|
||||||
|
stroke="currentColor" stroke-width="0.5" opacity="0.2"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
:points="chart.polyline"
|
||||||
|
fill="none" stroke="var(--color-primary)" stroke-width="2"
|
||||||
|
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-for="point in chart.points"
|
||||||
|
:key="point.reading.date"
|
||||||
|
:cx="point.x" :cy="point.y" r="2" fill="var(--color-primary)"
|
||||||
|
>
|
||||||
|
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
|
||||||
|
</circle>
|
||||||
|
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
|
||||||
|
<text x="2" :y="HEIGHT - 21" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
|
||||||
|
</svg>
|
||||||
|
<p v-else class="italic text-base-content/60">
|
||||||
|
Record the pulse on at least two days to see the graph.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
42
frontend/src/components/PushCard.vue
Normal file
42
frontend/src/components/PushCard.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { usePush } from "@/composables/usePush";
|
||||||
|
|
||||||
|
const push = usePush();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card bg-base-100 border border-base-300">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Reminders</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{{ push.status.value }}</p>
|
||||||
|
|
||||||
|
<div v-if="push.available.value" class="flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-primary flex-1" :disabled="push.busy.value" @click="push.toggle">
|
||||||
|
{{ push.subscription.value ? "Turn reminders off" : "Turn reminders on" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="push.subscription.value"
|
||||||
|
class="btn btn-outline flex-1"
|
||||||
|
:disabled="push.busy.value"
|
||||||
|
@click="push.sendTest"
|
||||||
|
>
|
||||||
|
Send test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/60">
|
||||||
|
A reminder is sent at each dose's reminder time, and only if it isn't already ticked
|
||||||
|
off. Set those times on the medication in the admin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details :open="!push.available.value">
|
||||||
|
<summary class="cursor-pointer text-sm text-base-content/70">Diagnostics</summary>
|
||||||
|
<ul class="mt-2 space-y-1">
|
||||||
|
<li v-for="check in push.checks.value" :key="check.name" class="text-xs text-base-content/70">
|
||||||
|
{{ check.ok ? "✅" : "❌" }} {{ check.name }}: {{ check.detail }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
195
frontend/src/composables/usePush.ts
Normal file
195
frontend/src/composables/usePush.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import {
|
||||||
|
trackerPushApiSendTest,
|
||||||
|
trackerPushApiSubscribe,
|
||||||
|
trackerPushApiUnsubscribe,
|
||||||
|
} from "@/api";
|
||||||
|
import { config } from "@/config";
|
||||||
|
import { errorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser wants the VAPID key as bytes, not the base64url we store it as. */
|
||||||
|
function decodeKey(key: string): Uint8Array {
|
||||||
|
const padded = (key + "=".repeat((4 - (key.length % 4)) % 4))
|
||||||
|
.replace(/-/g, "+")
|
||||||
|
.replace(/_/g, "/");
|
||||||
|
const raw = atob(padded);
|
||||||
|
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Push wiring for the reminders card. Push fails silently in a dozen ways,
|
||||||
|
* so every precondition is reported by name in `checks` rather than hidden.
|
||||||
|
*/
|
||||||
|
export function usePush() {
|
||||||
|
const status = ref("Checking…");
|
||||||
|
const busy = ref(false);
|
||||||
|
const registration = ref<ServiceWorkerRegistration | null>(null);
|
||||||
|
const subscription = ref<PushSubscription | null>(null);
|
||||||
|
|
||||||
|
const notificationState = () =>
|
||||||
|
typeof Notification === "undefined" ? "missing" : Notification.permission;
|
||||||
|
|
||||||
|
const preconditions = computed<Check[]>(() => [
|
||||||
|
{
|
||||||
|
name: "Server VAPID keys",
|
||||||
|
ok: Boolean(config.vapidPublicKey),
|
||||||
|
detail: config.vapidPublicKey
|
||||||
|
? config.vapidPublicKey.slice(0, 12) + "…"
|
||||||
|
: "not set — run: manage.py generate_vapid_keys, then set VAPID_PUBLIC_KEY and " +
|
||||||
|
"VAPID_PRIVATE_KEY in the server environment and restart",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Secure context (HTTPS)",
|
||||||
|
ok: window.isSecureContext,
|
||||||
|
detail: window.isSecureContext
|
||||||
|
? location.origin
|
||||||
|
: location.origin + " — push needs https:// (or localhost)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Service worker support",
|
||||||
|
ok: "serviceWorker" in navigator,
|
||||||
|
detail: "serviceWorker" in navigator ? "supported" : "not supported by this browser",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Push support",
|
||||||
|
ok: "PushManager" in window,
|
||||||
|
detail:
|
||||||
|
"PushManager" in window
|
||||||
|
? "supported"
|
||||||
|
: "not supported — on Android use Chrome; Firefox in a private window will not do",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Notification permission",
|
||||||
|
ok: notificationState() !== "denied" && notificationState() !== "missing",
|
||||||
|
detail: notificationState(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checks = computed<Check[]>(() => [
|
||||||
|
...preconditions.value,
|
||||||
|
{
|
||||||
|
name: "Service worker",
|
||||||
|
ok: Boolean(registration.value),
|
||||||
|
detail: registration.value
|
||||||
|
? "active at " + registration.value.scope
|
||||||
|
: "not registered yet — reload the page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "This device subscribed",
|
||||||
|
ok: Boolean(subscription.value),
|
||||||
|
detail: subscription.value ? subscription.value.endpoint.slice(0, 48) + "…" : "no",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Installed as an app",
|
||||||
|
ok: window.matchMedia("(display-mode: standalone)").matches,
|
||||||
|
detail: window.matchMedia("(display-mode: standalone)").matches
|
||||||
|
? "yes"
|
||||||
|
: "no — works in the browser too, but Android delivers reliably once installed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Doses with a reminder time",
|
||||||
|
ok: config.dosesWithReminders > 0,
|
||||||
|
detail:
|
||||||
|
`${config.dosesWithReminders} of ${config.activeDoses}` +
|
||||||
|
(config.dosesWithReminders
|
||||||
|
? ""
|
||||||
|
: " — set one on the medication in the admin, or nothing will ever fire"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Devices subscribed (this account)",
|
||||||
|
ok: config.subscribedDevices > 0,
|
||||||
|
detail: String(config.subscribedDevices),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// "denied" is recoverable through the browser's site settings, so it is not
|
||||||
|
// fatal here; the other failures mean the toggle would do nothing at all.
|
||||||
|
const fatal = computed(() =>
|
||||||
|
preconditions.value.filter((check) => !check.ok && check.name !== "Notification permission"),
|
||||||
|
);
|
||||||
|
const denied = computed(() => notificationState() === "denied");
|
||||||
|
const available = computed(() => fatal.value.length === 0 && !denied.value);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const blocked = fatal.value[0];
|
||||||
|
if (blocked) {
|
||||||
|
status.value = `Reminders unavailable — ${blocked.name}: ${blocked.detail}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (denied.value) {
|
||||||
|
status.value =
|
||||||
|
"Notifications are blocked for this site. Re-allow them in the browser's site settings.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
registration.value = await navigator.serviceWorker.ready;
|
||||||
|
subscription.value = await registration.value.pushManager.getSubscription();
|
||||||
|
status.value = subscription.value
|
||||||
|
? "Reminders are on for this device."
|
||||||
|
: "Reminders are off for this device.";
|
||||||
|
} catch (error) {
|
||||||
|
status.value = "Service worker failed: " + (error as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
if (subscription.value) {
|
||||||
|
await trackerPushApiUnsubscribe({
|
||||||
|
body: { endpoint: subscription.value.endpoint },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
await subscription.value.unsubscribe();
|
||||||
|
subscription.value = null;
|
||||||
|
} else {
|
||||||
|
if ((await Notification.requestPermission()) !== "granted") return;
|
||||||
|
const ready = await navigator.serviceWorker.ready;
|
||||||
|
const fresh = await ready.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: decodeKey(config.vapidPublicKey) as BufferSource,
|
||||||
|
});
|
||||||
|
const json = fresh.toJSON();
|
||||||
|
await trackerPushApiSubscribe({
|
||||||
|
body: {
|
||||||
|
endpoint: json.endpoint!,
|
||||||
|
keys: { p256dh: json.keys!.p256dh!, auth: json.keys!.auth! },
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
subscription.value = fresh;
|
||||||
|
}
|
||||||
|
status.value = subscription.value
|
||||||
|
? "Reminders are on for this device."
|
||||||
|
: "Reminders are off for this device.";
|
||||||
|
} catch (error) {
|
||||||
|
status.value = errorMessage(error);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest() {
|
||||||
|
busy.value = true;
|
||||||
|
status.value = "Sending…";
|
||||||
|
try {
|
||||||
|
const { data } = await trackerPushApiSendTest({ throwOnError: true });
|
||||||
|
status.value = (data as { detail: string }).detail;
|
||||||
|
} catch (error) {
|
||||||
|
status.value = errorMessage(error);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
return { status, busy, subscription, checks, available, toggle, sendTest };
|
||||||
|
}
|
||||||
12
frontend/src/config.ts
Normal file
12
frontend/src/config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/** Per-page-load facts Django embeds in the shell template as a json_script. */
|
||||||
|
export interface OsirisConfig {
|
||||||
|
vapidPublicKey: string;
|
||||||
|
csrfToken: string;
|
||||||
|
activeDoses: number;
|
||||||
|
dosesWithReminders: number;
|
||||||
|
subscribedDevices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config: OsirisConfig = JSON.parse(
|
||||||
|
document.getElementById("osiris-config")?.textContent ?? "{}",
|
||||||
|
);
|
||||||
19
frontend/src/lib/dates.ts
Normal file
19
frontend/src/lib/dates.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/** Local date as YYYY-MM-DD — what the API expects and returns. */
|
||||||
|
export function isoDate(date: Date = new Date()): string {
|
||||||
|
const offset = date.getTimezoneOffset();
|
||||||
|
return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftDate(iso: string, days: number): string {
|
||||||
|
// Noon dodges DST edges: shifting at midnight can land on the same day.
|
||||||
|
const date = new Date(`${iso}T12:00:00`);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return isoDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDay(
|
||||||
|
iso: string,
|
||||||
|
options: Intl.DateTimeFormatOptions = { weekday: "short", day: "numeric", month: "short" },
|
||||||
|
): string {
|
||||||
|
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-GB", options);
|
||||||
|
}
|
||||||
9
frontend/src/lib/errors.ts
Normal file
9
frontend/src/lib/errors.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** API errors are `{detail}` bodies from ninja; network failures are Errors. */
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const known = error as { detail?: string; message?: string };
|
||||||
|
if (known.detail) return known.detail;
|
||||||
|
if (known.message) return known.message;
|
||||||
|
}
|
||||||
|
return "Request failed.";
|
||||||
|
}
|
||||||
28
frontend/src/main.ts
Normal file
28
frontend/src/main.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { client } from "./api/client.gen";
|
||||||
|
import { config } from "./config";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
// Same-origin requests, authenticated by the Django session cookie.
|
||||||
|
client.setConfig({ baseUrl: "", credentials: "same-origin" });
|
||||||
|
client.interceptors.request.use((request) => {
|
||||||
|
if (!["GET", "HEAD", "OPTIONS"].includes(request.method)) {
|
||||||
|
request.headers.set("X-CSRFToken", config.csrfToken);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
// The app lives under /admin/ so it shares the admin's login session.
|
||||||
|
history: createWebHistory("/admin/"),
|
||||||
|
routes: [
|
||||||
|
{ path: "/today/", name: "today", component: () => import("./views/TodayView.vue") },
|
||||||
|
{ path: "/recap/", name: "recap", component: () => import("./views/RecapView.vue") },
|
||||||
|
{ path: "/:pathMatch(.*)*", redirect: { name: "today" } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
4
frontend/src/style.css
Normal file
4
frontend/src/style.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: light --default, dark --prefersdark;
|
||||||
|
}
|
||||||
102
frontend/src/sw.ts
Normal file
102
frontend/src/sw.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// Built by vite-plugin-pwa (injectManifest) into dist/sw.js and served by
|
||||||
|
// Django from / (not /static/): a worker under /static/ could only control
|
||||||
|
// /static/, not the /admin/ pages it exists to cache.
|
||||||
|
import { clientsClaim } from "workbox-core";
|
||||||
|
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||||
|
import { registerRoute, setCatchHandler } from "workbox-routing";
|
||||||
|
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Take over immediately, as the old hand-rolled worker did.
|
||||||
|
void self.skipWaiting();
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Every built asset, precached by its hashed name; outdated entries are dropped
|
||||||
|
// on activate, which is what stops a stale Today page surviving a deploy.
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// The pre-Workbox caches, so upgraded devices don't keep dead data around.
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key.startsWith("osiris-v")).map((key) => caches.delete(key))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static files outside the build (icons, admin assets) change rarely: serve
|
||||||
|
// from cache, refresh in the background.
|
||||||
|
registerRoute(
|
||||||
|
({ request, url }) => request.method === "GET" && url.pathname.startsWith("/static/"),
|
||||||
|
new StaleWhileRevalidate({ cacheName: "osiris-static" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pages: always prefer the live version, fall back to the last one seen so the
|
||||||
|
// app still opens (read-only) with no signal. The API is deliberately not
|
||||||
|
// routed at all — a stale or replayed dose would be a lie.
|
||||||
|
registerRoute(
|
||||||
|
({ request, url }) => request.mode === "navigate" && !url.pathname.startsWith("/api/"),
|
||||||
|
new NetworkFirst({ cacheName: "osiris-pages" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
setCatchHandler(({ request }) => {
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
"<!doctype html><meta name='viewport' content='width=device-width,initial-scale=1'>" +
|
||||||
|
"<body style='font-family:system-ui;background:#202a35;color:#eee;padding:2rem;text-align:center'>" +
|
||||||
|
"<h1>Offline</h1><p>Osiris needs a connection to load. Try again once you're back online.</p>",
|
||||||
|
{ headers: { "Content-Type": "text/html; charset=utf-8" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(Response.error());
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PushPayload {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
tag?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
let payload: PushPayload = {};
|
||||||
|
try {
|
||||||
|
payload = event.data ? (event.data.json() as PushPayload) : {};
|
||||||
|
} catch {
|
||||||
|
payload = { title: "Osiris", body: event.data ? event.data.text() : "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: NotificationOptions & { renotify?: boolean } = {
|
||||||
|
body: payload.body || "",
|
||||||
|
icon: "/static/icons/icon-192.png",
|
||||||
|
badge: "/static/icons/icon-192.png",
|
||||||
|
// Same tag replaces the previous notification instead of stacking.
|
||||||
|
tag: payload.tag || "osiris",
|
||||||
|
renotify: true,
|
||||||
|
requireInteraction: true, // A dose reminder should wait, not fade away.
|
||||||
|
data: { url: payload.url || "/admin/today/" },
|
||||||
|
};
|
||||||
|
event.waitUntil(self.registration.showNotification(payload.title || "Osiris", options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const data = event.notification.data as { url?: string } | undefined;
|
||||||
|
const url = data?.url || "/admin/today/";
|
||||||
|
|
||||||
|
// Focus the app if it is already open rather than opening a second window.
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => {
|
||||||
|
for (const client of windows) {
|
||||||
|
if (client.url.includes(url) && "focus" in client) return client.focus();
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
133
frontend/src/views/RecapView.vue
Normal file
133
frontend/src/views/RecapView.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import {
|
||||||
|
trackerApiGetRecap,
|
||||||
|
trackerApiListDailyNotes,
|
||||||
|
trackerApiListPulse,
|
||||||
|
trackerApiPulseStats,
|
||||||
|
type DailyNoteOut,
|
||||||
|
type PulseOut,
|
||||||
|
type PulseStatsOut,
|
||||||
|
type RecapDayOut,
|
||||||
|
} from "@/api";
|
||||||
|
import PulseChart from "@/components/PulseChart.vue";
|
||||||
|
import { formatDay, isoDate } from "@/lib/dates";
|
||||||
|
import { errorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
const recap = ref<RecapDayOut[]>([]);
|
||||||
|
const readings = ref<PulseOut[]>([]);
|
||||||
|
const stats = ref<PulseStatsOut | null>(null);
|
||||||
|
const notes = ref<DailyNoteOut[]>([]);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [recapResult, pulseResult, statsResult, notesResult] = await Promise.all([
|
||||||
|
trackerApiGetRecap({ throwOnError: true }),
|
||||||
|
trackerApiListPulse({ query: { days: 90 }, throwOnError: true }),
|
||||||
|
trackerApiPulseStats({ query: { days: 90 }, throwOnError: true }),
|
||||||
|
trackerApiListDailyNotes({ throwOnError: true }),
|
||||||
|
]);
|
||||||
|
recap.value = recapResult.data;
|
||||||
|
readings.value = pulseResult.data;
|
||||||
|
stats.value = statsResult.data;
|
||||||
|
notes.value = notesResult.data;
|
||||||
|
} catch (raised) {
|
||||||
|
error.value = errorMessage(raised);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="mb-4 text-center"><strong>Recap</strong></header>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error mb-3 text-sm" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Doses — last 14 days</h2>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th class="text-center">Given</th>
|
||||||
|
<th class="text-right">Pulse</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in recap" :key="row.date">
|
||||||
|
<td>
|
||||||
|
<router-link class="link link-hover font-semibold" :to="{ name: 'today', query: { date: row.date } }">
|
||||||
|
{{ formatDay(row.date) }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="row.date === isoDate()" class="ml-1 text-xs text-base-content/60">today</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span v-if="row.complete">✅</span>
|
||||||
|
<span v-else class="font-semibold text-error">{{ row.taken }}/{{ row.expected }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<template v-if="row.bpm">{{ row.bpm }}</template>
|
||||||
|
<span v-else class="text-base-content/40">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-base-content/60">
|
||||||
|
✅ means every active dose was ticked off that day; otherwise the count given out of
|
||||||
|
expected. Tap a day to fill it in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Pulse — last 90 days</h2>
|
||||||
|
<PulseChart :readings="readings" />
|
||||||
|
<div v-if="stats && stats.count" class="flex flex-wrap gap-5">
|
||||||
|
<div class="text-sm text-base-content/60">
|
||||||
|
Average<strong class="block text-lg text-base-content">{{ stats.average }} bpm</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-base-content/60">
|
||||||
|
Range<strong class="block text-lg text-base-content">{{ stats.minimum }}–{{ stats.maximum }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-base-content/60">
|
||||||
|
Readings<strong class="block text-lg text-base-content">{{ stats.count }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-base-content/60">
|
||||||
|
Trend
|
||||||
|
<strong class="block text-lg text-base-content">
|
||||||
|
<template v-if="stats.trend_bpm_per_day == null">—</template>
|
||||||
|
<template v-else-if="stats.trend_bpm_per_day > 0">↑ +{{ stats.trend_bpm_per_day }}</template>
|
||||||
|
<template v-else-if="stats.trend_bpm_per_day < 0">↓ {{ stats.trend_bpm_per_day }}</template>
|
||||||
|
<template v-else>→ steady</template>
|
||||||
|
<span v-if="stats.trend_bpm_per_day" class="text-xs"> bpm/day</span>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Daily notes</h2>
|
||||||
|
<div
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.date"
|
||||||
|
class="border-b border-base-300 py-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
class="link link-hover text-sm text-base-content/60"
|
||||||
|
:to="{ name: 'today', query: { date: note.date } }"
|
||||||
|
>
|
||||||
|
{{ formatDay(note.date, { weekday: "long", day: "numeric", month: "long" }) }}
|
||||||
|
</router-link>
|
||||||
|
<p class="whitespace-pre-line">{{ note.body }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="!notes.length" class="italic text-base-content/60">
|
||||||
|
No notes yet — write one on the Today page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
271
frontend/src/views/TodayView.vue
Normal file
271
frontend/src/views/TodayView.vue
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
trackerApiDeletePulse,
|
||||||
|
trackerApiGetDay,
|
||||||
|
trackerApiGetGlobalNote,
|
||||||
|
trackerApiLogDose,
|
||||||
|
trackerApiRecordPulse,
|
||||||
|
trackerApiSaveDailyNote,
|
||||||
|
trackerApiSaveGlobalNote,
|
||||||
|
type DayOut,
|
||||||
|
type DoseStatusOut,
|
||||||
|
type GlobalNoteOut,
|
||||||
|
} from "@/api";
|
||||||
|
import PushCard from "@/components/PushCard.vue";
|
||||||
|
import { formatDay, isoDate, shiftDate } from "@/lib/dates";
|
||||||
|
import { errorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const date = computed(() =>
|
||||||
|
typeof route.query.date === "string" && route.query.date ? route.query.date : isoDate(),
|
||||||
|
);
|
||||||
|
const isToday = computed(() => date.value === isoDate());
|
||||||
|
|
||||||
|
const day = ref<DayOut | null>(null);
|
||||||
|
const globalNote = ref<GlobalNoteOut | null>(null);
|
||||||
|
// number | string: v-model on <input type="number"> auto-casts to a number,
|
||||||
|
// but an empty field (and applyDay) yields a string.
|
||||||
|
const pulseInput = ref<number | string>("");
|
||||||
|
const noteInput = ref("");
|
||||||
|
const globalNoteInput = ref("");
|
||||||
|
const flash = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
function applyDay(fresh: DayOut) {
|
||||||
|
day.value = fresh;
|
||||||
|
pulseInput.value = fresh.pulse ? String(fresh.pulse.bpm) : "";
|
||||||
|
noteInput.value = fresh.note?.body ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFlash(message: string) {
|
||||||
|
error.value = "";
|
||||||
|
flash.value = message;
|
||||||
|
setTimeout(() => (flash.value = ""), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(request: Promise<{ data: T }>, done: (data: T) => void, saved?: string) {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const { data } = await request;
|
||||||
|
done(data);
|
||||||
|
if (saved) showFlash(saved);
|
||||||
|
} catch (raised) {
|
||||||
|
error.value = errorMessage(raised);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
date,
|
||||||
|
() => run(trackerApiGetDay({ query: { date: date.value }, throwOnError: true }), applyDay),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() =>
|
||||||
|
run(trackerApiGetGlobalNote({ throwOnError: true }), (note) => {
|
||||||
|
globalNote.value = note;
|
||||||
|
globalNoteInput.value = note.body;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Grouped morning → night; doses arrive already sorted that way. */
|
||||||
|
const doseGroups = computed(() => {
|
||||||
|
const groups: { label: string; doses: DoseStatusOut[] }[] = [];
|
||||||
|
for (const dose of day.value?.doses ?? []) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last?.label === dose.time_of_day_label) last.doses.push(dose);
|
||||||
|
else groups.push({ label: dose.time_of_day_label, doses: [dose] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setDose(dose: DoseStatusOut, taken: boolean) {
|
||||||
|
return run(
|
||||||
|
trackerApiLogDose({
|
||||||
|
body: { scheduled_dose_id: dose.id, date: date.value, taken, notes: dose.notes },
|
||||||
|
throwOnError: true,
|
||||||
|
}),
|
||||||
|
applyDay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePulse() {
|
||||||
|
const bpm = String(pulseInput.value).trim();
|
||||||
|
if (!bpm) {
|
||||||
|
// Clearing the field removes the reading for that day.
|
||||||
|
if (!day.value?.pulse) return;
|
||||||
|
return run(
|
||||||
|
trackerApiDeletePulse({ path: { date: date.value }, throwOnError: true }),
|
||||||
|
() => run(trackerApiGetDay({ query: { date: date.value }, throwOnError: true }), applyDay),
|
||||||
|
"Pulse cleared.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return run(
|
||||||
|
trackerApiRecordPulse({ body: { bpm: Number(bpm), date: date.value }, throwOnError: true }),
|
||||||
|
() => run(trackerApiGetDay({ query: { date: date.value }, throwOnError: true }), applyDay),
|
||||||
|
"Pulse saved.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNote() {
|
||||||
|
return run(
|
||||||
|
trackerApiSaveDailyNote({
|
||||||
|
body: { body: noteInput.value, date: date.value },
|
||||||
|
throwOnError: true,
|
||||||
|
}),
|
||||||
|
applyDay,
|
||||||
|
"Note saved.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGlobalNote() {
|
||||||
|
return run(
|
||||||
|
trackerApiSaveGlobalNote({ body: { body: globalNoteInput.value }, throwOnError: true }),
|
||||||
|
(note) => {
|
||||||
|
globalNote.value = note;
|
||||||
|
globalNoteInput.value = note.body;
|
||||||
|
},
|
||||||
|
"Shared note saved.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="mb-4 flex items-center justify-between gap-2">
|
||||||
|
<router-link
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:to="{ name: 'today', query: { date: shiftDate(date, -1) } }"
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</router-link>
|
||||||
|
<div class="text-center">
|
||||||
|
<strong>{{ formatDay(date, { weekday: "long", day: "numeric", month: "long" }) }}</strong>
|
||||||
|
<div v-if="isToday" class="text-xs text-base-content/60">Today</div>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="!isToday"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:to="{ name: 'today', query: { date: shiftDate(date, 1) } }"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</router-link>
|
||||||
|
<span v-else class="w-16"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error mb-3 text-sm" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Medication</h2>
|
||||||
|
<template v-if="doseGroups.length">
|
||||||
|
<div v-for="group in doseGroups" :key="group.label">
|
||||||
|
<div class="text-xs uppercase tracking-wider text-base-content/60 mb-1">
|
||||||
|
{{ group.label }}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
v-for="dose in group.doses"
|
||||||
|
:key="dose.id"
|
||||||
|
class="flex cursor-pointer items-center gap-3 border-b border-base-300 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-lg checkbox-primary"
|
||||||
|
:checked="dose.taken === true"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="setDose(dose, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="font-semibold" :class="{ 'text-primary': dose.taken }">
|
||||||
|
{{ dose.medication_name }}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span class="text-sm text-base-content/60">
|
||||||
|
{{ dose.amount }}<template v-if="dose.taken === null"> — not recorded</template>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="day" class="italic text-base-content/60">
|
||||||
|
No active medication yet. <a class="link" href="/admin/tracker/medication/add/">Add one</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Pulse</h2>
|
||||||
|
<label for="bpm" class="text-sm text-base-content/60">
|
||||||
|
Beats per minute (leave empty to clear)
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="bpm"
|
||||||
|
v-model="pulseInput"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
min="20"
|
||||||
|
max="400"
|
||||||
|
placeholder="e.g. 180"
|
||||||
|
class="input input-lg flex-1 text-2xl"
|
||||||
|
@keyup.enter="savePulse"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary btn-lg" :disabled="busy" @click="savePulse">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Note for the day</h2>
|
||||||
|
<label for="note" class="text-sm text-base-content/60">
|
||||||
|
Appetite, behaviour, anything worth remembering (leave empty to clear)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="note"
|
||||||
|
v-model="noteInput"
|
||||||
|
class="textarea w-full"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. Ate well, more playful than yesterday"
|
||||||
|
></textarea>
|
||||||
|
<button class="btn btn-primary" :disabled="busy" @click="saveNote">
|
||||||
|
Save note for {{ formatDay(date) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 border border-base-300 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title text-base">Shared note</h2>
|
||||||
|
<label for="global-note" class="text-sm text-base-content/60">
|
||||||
|
Instructions and things to do — one note for everyone, on every day
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="global-note"
|
||||||
|
v-model="globalNoteInput"
|
||||||
|
class="textarea w-full"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. Vet appointment Friday — bring the pulse log"
|
||||||
|
></textarea>
|
||||||
|
<p v-if="globalNote?.updated_at" class="text-xs text-base-content/60">
|
||||||
|
Last updated {{ new Date(globalNote.updated_at).toLocaleString("en-GB") }}
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary" :disabled="busy" @click="saveGlobalNote">
|
||||||
|
Save shared note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PushCard />
|
||||||
|
|
||||||
|
<div v-if="flash" class="toast toast-center toast-bottom mb-20">
|
||||||
|
<div class="alert alert-success py-2 text-sm">{{ flash }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
frontend/tsconfig.json
Normal file
10
frontend/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["src/**/*", "src/**/*.vue", "vite.config.ts", "openapi-ts.config.ts"],
|
||||||
|
"exclude": ["src/sw.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/tsconfig.sw.json
Normal file
13
frontend/tsconfig.sw.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2020", "WebWorker"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/sw.ts"]
|
||||||
|
}
|
||||||
46
frontend/vite.config.ts
Normal file
46
frontend/vite.config.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
// Our own worker (push handlers, custom caching); the plugin builds it
|
||||||
|
// and injects the precache list of hashed assets as self.__WB_MANIFEST.
|
||||||
|
strategies: "injectManifest",
|
||||||
|
srcDir: "src",
|
||||||
|
filename: "sw.ts",
|
||||||
|
// Django owns both: the worker is served from /sw.js (root scope, not
|
||||||
|
// /static/) and the manifest is a template resolving hashed icon URLs.
|
||||||
|
injectRegister: null,
|
||||||
|
manifest: false,
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
injectManifest: {
|
||||||
|
// The worker is served from /sw.js, so relative entries would resolve
|
||||||
|
// against the root; the built assets actually live under /static/.
|
||||||
|
modifyURLPrefix: { "": "/static/" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
// Built assets are collected by Django and served under /static/.
|
||||||
|
base: "/static/",
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": "/src" },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
// Absolute asset URLs, so the page served by Django loads them from Vite.
|
||||||
|
origin: "http://localhost:5173",
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
manifest: true,
|
||||||
|
outDir: "dist",
|
||||||
|
rollupOptions: {
|
||||||
|
input: "src/main.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@ -52,6 +53,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"django_vite",
|
||||||
"accounts",
|
"accounts",
|
||||||
"tracker",
|
"tracker",
|
||||||
]
|
]
|
||||||
@ -136,8 +138,19 @@ USE_TZ = True
|
|||||||
|
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "static/"
|
||||||
STATIC_ROOT = BASE_DIR / "static"
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output.
|
# PWA icons live in assets/; frontend/dist is the Vite build output. STATIC_ROOT
|
||||||
STATICFILES_DIRS = [BASE_DIR / "assets"]
|
# is only the collectstatic destination.
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / "assets", BASE_DIR / "frontend" / "dist"]
|
||||||
|
|
||||||
|
# The Vue frontend, served through django-vite's template tags. With
|
||||||
|
# DJANGO_VITE_DEV=1 the tags point at the Vite dev server (npm run dev) for hot
|
||||||
|
# reload; otherwise they resolve hashed files from the built manifest.
|
||||||
|
DJANGO_VITE = {
|
||||||
|
"default": {
|
||||||
|
"dev_mode": os.environ.get("DJANGO_VITE_DEV", "0") == "1",
|
||||||
|
"manifest_path": BASE_DIR / "frontend" / "dist" / ".vite" / "manifest.json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
@ -148,6 +161,13 @@ STORAGES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if "test" in sys.argv:
|
||||||
|
# The manifest storage refuses to serve anything collectstatic hasn't seen,
|
||||||
|
# which would make every template that names a static file fail under test.
|
||||||
|
STORAGES["staticfiles"] = {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
}
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from django.urls import include, path
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from osiris.api import api
|
from osiris.api import api
|
||||||
|
from tracker.views import service_worker
|
||||||
|
|
||||||
admin.site.site_header = "Osiris"
|
admin.site.site_header = "Osiris"
|
||||||
admin.site.site_title = "Osiris"
|
admin.site.site_title = "Osiris"
|
||||||
@ -13,13 +14,7 @@ urlpatterns = [
|
|||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
# Must be served from the root: a worker under /static/ could only control
|
# Must be served from the root: a worker under /static/ could only control
|
||||||
# /static/, not the /admin/ pages it exists to cache.
|
# /static/, not the /admin/ pages it exists to cache.
|
||||||
path(
|
path("sw.js", service_worker, name="service-worker"),
|
||||||
"sw.js",
|
|
||||||
TemplateView.as_view(
|
|
||||||
template_name="sw.js", content_type="application/javascript"
|
|
||||||
),
|
|
||||||
name="service-worker",
|
|
||||||
),
|
|
||||||
# A template, not a static file, so {% static %} resolves the icons to their
|
# A template, not a static file, so {% static %} resolves the icons to their
|
||||||
# hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
|
# hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
|
||||||
path(
|
path(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.2",
|
"django>=5.2",
|
||||||
"django-ninja>=1.4",
|
"django-ninja>=1.4",
|
||||||
|
"django-vite>=3.1.0",
|
||||||
"gunicorn>=26.0.0",
|
"gunicorn>=26.0.0",
|
||||||
"pywebpush>=2.3.0",
|
"pywebpush>=2.3.0",
|
||||||
"whitenoise>=6.12.0",
|
"whitenoise>=6.12.0",
|
||||||
|
|||||||
109
templates/sw.js
109
templates/sw.js
@ -1,109 +0,0 @@
|
|||||||
// Served from / (not /static/) so its scope covers /admin/today/.
|
|
||||||
// Bump on any change here or to the cached pages: activate() drops older caches,
|
|
||||||
// which is what stops a stale Today page surviving a deploy.
|
|
||||||
const CACHE = "osiris-v2";
|
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches
|
|
||||||
.keys()
|
|
||||||
.then((keys) =>
|
|
||||||
Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))
|
|
||||||
)
|
|
||||||
.then(() => self.clients.claim())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
|
||||||
let payload = {};
|
|
||||||
try {
|
|
||||||
payload = event.data ? event.data.json() : {};
|
|
||||||
} catch (e) {
|
|
||||||
payload = { title: "Osiris", body: event.data ? event.data.text() : "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(payload.title || "Osiris", {
|
|
||||||
body: payload.body || "",
|
|
||||||
icon: "/static/icons/icon-192.png",
|
|
||||||
badge: "/static/icons/icon-192.png",
|
|
||||||
// Same tag replaces the previous notification instead of stacking.
|
|
||||||
tag: payload.tag || "osiris",
|
|
||||||
renotify: true,
|
|
||||||
requireInteraction: true, // A dose reminder should wait, not fade away.
|
|
||||||
data: { url: payload.url || "/admin/today/" },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const url = (event.notification.data && event.notification.data.url) || "/admin/today/";
|
|
||||||
|
|
||||||
// Focus the app if it is already open rather than opening a second window.
|
|
||||||
event.waitUntil(
|
|
||||||
self.clients
|
|
||||||
.matchAll({ type: "window", includeUncontrolled: true })
|
|
||||||
.then((windows) => {
|
|
||||||
for (const client of windows) {
|
|
||||||
if (client.url.includes(url) && "focus" in client) return client.focus();
|
|
||||||
}
|
|
||||||
return self.clients.openWindow(url);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
|
||||||
const { request } = event;
|
|
||||||
|
|
||||||
// Never touch writes or the API: a stale or replayed dose would be a lie.
|
|
||||||
if (request.method !== "GET" || new URL(request.url).pathname.startsWith("/api/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static assets change rarely: serve from cache, refresh in the background.
|
|
||||||
if (new URL(request.url).pathname.startsWith("/static/")) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(CACHE).then(async (cache) => {
|
|
||||||
const cached = await cache.match(request);
|
|
||||||
const network = fetch(request)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) cache.put(request, response.clone());
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => cached);
|
|
||||||
return cached || network;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pages: always prefer the live version, fall back to the last one seen so the
|
|
||||||
// app still opens (read-only) with no signal.
|
|
||||||
event.respondWith(
|
|
||||||
fetch(request)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
const copy = response.clone();
|
|
||||||
caches.open(CACHE).then((cache) => cache.put(request, copy));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const cached = await caches.match(request);
|
|
||||||
return (
|
|
||||||
cached ||
|
|
||||||
new Response(
|
|
||||||
"<!doctype html><meta name='viewport' content='width=device-width,initial-scale=1'>" +
|
|
||||||
"<body style='font-family:system-ui;background:#202a35;color:#eee;padding:2rem;text-align:center'>" +
|
|
||||||
"<h1>Offline</h1><p>Osiris needs a connection to load. Try again once you're back online.</p>",
|
|
||||||
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
24
templates/tracker/app.html
Normal file
24
templates/tracker/app.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{% load django_vite static %}<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Osiris</title>
|
||||||
|
<link rel="manifest" href="{% url 'manifest' %}">
|
||||||
|
<meta name="theme-color" content="#202a35">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="apple-touch-icon" href="{% static 'icons/icon-192.png' %}">
|
||||||
|
<link rel="icon" href="{% static 'icons/icon-192.png' %}">
|
||||||
|
{% vite_hmr_client %}
|
||||||
|
{% vite_asset 'src/main.ts' %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
{{ config|json_script:"osiris-config" }}
|
||||||
|
<script>
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,129 +0,0 @@
|
|||||||
{% extends "admin/base_site.html" %}
|
|
||||||
|
|
||||||
{% block title %}Osiris — Recap{% endblock %}
|
|
||||||
|
|
||||||
{% block extrastyle %}
|
|
||||||
{{ block.super }}
|
|
||||||
<style>
|
|
||||||
/* Mobile-first, same shell as the Today page. */
|
|
||||||
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
|
|
||||||
.osiris-card {
|
|
||||||
background: var(--body-bg);
|
|
||||||
border: 1px solid var(--hairline-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.osiris-recap { width: 100%; border-collapse: collapse; }
|
|
||||||
.osiris-recap td, .osiris-recap th {
|
|
||||||
padding: 10px 4px;
|
|
||||||
border-bottom: 1px solid var(--hairline-color);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.osiris-recap tr:last-child td { border-bottom: none; }
|
|
||||||
.osiris-recap .status { text-align: center; width: 4em; }
|
|
||||||
.osiris-recap .bpm { text-align: right; width: 5em; }
|
|
||||||
.osiris-recap .incomplete { color: var(--error-fg, #ba2121); font-weight: 600; }
|
|
||||||
.osiris-recap a { font-weight: 600; }
|
|
||||||
.osiris-note-entry { padding: 10px 0; border-bottom: 1px solid var(--hairline-color); }
|
|
||||||
.osiris-note-entry:last-child { border-bottom: none; }
|
|
||||||
.osiris-note-entry p { margin: 4px 0 0; white-space: pre-line; }
|
|
||||||
.dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
|
|
||||||
.osiris-stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; }
|
|
||||||
.osiris-stats div { font-size: 0.85rem; color: var(--body-quiet-color); }
|
|
||||||
.osiris-stats strong { display: block; font-size: 1.15rem; color: var(--body-fg); }
|
|
||||||
.osiris-chart { width: 100%; height: auto; overflow: visible; }
|
|
||||||
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block breadcrumbs %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="osiris-card">
|
|
||||||
<h2 style="margin-top:0">Doses — last 14 days</h2>
|
|
||||||
<table class="osiris-recap">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Day</th>
|
|
||||||
<th class="status">Given</th>
|
|
||||||
<th class="bpm">Pulse</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in recap_days %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'tracker:today' %}?date={{ row.date|date:'Y-m-d' }}">{{ row.date|date:"D j M" }}</a>
|
|
||||||
{% if forloop.first %}<span class="dose-detail">today</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="status">
|
|
||||||
{% if row.complete %}
|
|
||||||
✅
|
|
||||||
{% else %}
|
|
||||||
<span class="incomplete">{{ row.taken }}/{{ row.expected }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="bpm">{% if row.bpm %}{{ row.bpm }}{% else %}<span class="dose-detail">—</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="dose-detail" style="margin-bottom:0">
|
|
||||||
✅ means every active dose was ticked off that day; otherwise the count given
|
|
||||||
out of expected. Tap a day to fill it in.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="osiris-card">
|
|
||||||
<h2 style="margin-top:0">Pulse — last 90 days</h2>
|
|
||||||
{% if chart %}
|
|
||||||
<svg class="osiris-chart" viewBox="0 0 {{ chart.width }} {{ chart.height }}" role="img"
|
|
||||||
aria-label="Pulse from {{ chart.first_date }} to {{ chart.last_date }}">
|
|
||||||
<line x1="24" y1="24" x2="{{ chart.width|add:'-24' }}" y2="24"
|
|
||||||
stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
|
|
||||||
<line x1="24" y1="{{ chart.height|add:'-24' }}" x2="{{ chart.width|add:'-24' }}"
|
|
||||||
y2="{{ chart.height|add:'-24' }}" stroke="currentColor" stroke-width="0.5" opacity="0.2"/>
|
|
||||||
<polyline points="{{ chart.polyline }}" fill="none" stroke="#79aec8" stroke-width="2"
|
|
||||||
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
|
|
||||||
{% for point in chart.points %}
|
|
||||||
<circle cx="{{ point.x }}" cy="{{ point.y }}" r="2" fill="#417690">
|
|
||||||
<title>{{ point.reading.date }} — {{ point.reading.bpm }} bpm</title>
|
|
||||||
</circle>
|
|
||||||
{% endfor %}
|
|
||||||
<text x="2" y="27" font-size="8" fill="currentColor" opacity="0.6">{{ chart.high }}</text>
|
|
||||||
<text x="2" y="{{ chart.height|add:'-21' }}" font-size="8" fill="currentColor" opacity="0.6">{{ chart.low }}</text>
|
|
||||||
</svg>
|
|
||||||
<div class="osiris-stats">
|
|
||||||
<div>Average<strong>{{ chart.average }} bpm</strong></div>
|
|
||||||
<div>Range<strong>{{ chart.low }}–{{ chart.high }}</strong></div>
|
|
||||||
<div>Readings<strong>{{ reading_count }}</strong></div>
|
|
||||||
<div>
|
|
||||||
Trend
|
|
||||||
<strong>
|
|
||||||
{% if chart.trend > 0 %}↑ +{{ chart.trend }}{% elif chart.trend < 0 %}↓ {{ chart.trend }}{% else %}→ steady{% endif %}
|
|
||||||
{% if chart.trend %}<span style="font-size:0.75rem">bpm/day</span>{% endif %}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="osiris-empty">Record the pulse on at least two days to see the graph.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="osiris-card">
|
|
||||||
<h2 style="margin-top:0">Daily notes</h2>
|
|
||||||
{% for note in daily_notes %}
|
|
||||||
<div class="osiris-note-entry">
|
|
||||||
<a href="{% url 'tracker:today' %}?date={{ note.date|date:'Y-m-d' }}" class="dose-detail">
|
|
||||||
{{ note.date|date:"l j F" }}
|
|
||||||
</a>
|
|
||||||
<p>{{ note.body }}</p>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<p class="osiris-empty">No notes yet — write one on the Today page.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="text-align:center"><a href="{% url 'tracker:today' %}">← Back to today</a></p>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,362 +0,0 @@
|
|||||||
{% extends "admin/base_site.html" %}
|
|
||||||
|
|
||||||
{% block title %}Osiris — {{ day.date|date:"D d M" }}{% endblock %}
|
|
||||||
|
|
||||||
{% block extrastyle %}
|
|
||||||
{{ block.super }}
|
|
||||||
<style>
|
|
||||||
/* Mobile-first: big touch targets, single column, no horizontal scrolling. */
|
|
||||||
#content { padding: 12px; max-width: 640px; margin: 0 auto; }
|
|
||||||
.osiris-card {
|
|
||||||
background: var(--body-bg);
|
|
||||||
border: 1px solid var(--hairline-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.osiris-daynav {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: 8px; margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.osiris-daynav a, .osiris-daynav strong { font-size: 1rem; }
|
|
||||||
.osiris-daynav .today-label { text-align: center; flex: 1; }
|
|
||||||
|
|
||||||
.osiris-dose {
|
|
||||||
display: flex; align-items: center; gap: 14px;
|
|
||||||
padding: 14px 4px;
|
|
||||||
border-bottom: 1px solid var(--hairline-color);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.osiris-dose:last-of-type { border-bottom: none; }
|
|
||||||
.osiris-dose input[type="checkbox"] {
|
|
||||||
width: 28px; height: 28px; flex: none; margin: 0;
|
|
||||||
}
|
|
||||||
.osiris-dose .dose-name { font-weight: 600; }
|
|
||||||
.osiris-dose .dose-detail { color: var(--body-quiet-color); font-size: 0.85rem; }
|
|
||||||
.osiris-dose.is-taken .dose-name { color: var(--object-tools-bg, #417690); }
|
|
||||||
|
|
||||||
.osiris-timeofday {
|
|
||||||
text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.06em;
|
|
||||||
color: var(--body-quiet-color); margin: 16px 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.osiris-note textarea {
|
|
||||||
width: 100%; min-height: 90px; padding: 12px; font: inherit;
|
|
||||||
border-radius: 8px; border: 1px solid var(--border-color);
|
|
||||||
background: var(--body-bg); color: var(--body-fg); resize: vertical;
|
|
||||||
}
|
|
||||||
.osiris-pulse input[type="number"] {
|
|
||||||
font-size: 1.6rem; width: 100%; padding: 12px;
|
|
||||||
border-radius: 8px; border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
.osiris-save {
|
|
||||||
width: 100%; padding: 16px; font-size: 1.05rem; font-weight: 600;
|
|
||||||
border: none; border-radius: 10px; cursor: pointer;
|
|
||||||
background: var(--button-bg); color: var(--button-fg);
|
|
||||||
}
|
|
||||||
.osiris-empty { color: var(--body-quiet-color); font-style: italic; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block breadcrumbs %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="osiris-daynav">
|
|
||||||
<a href="?date={{ previous_date|date:'Y-m-d' }}">← Prev</a>
|
|
||||||
<span class="today-label">
|
|
||||||
<strong>{{ day.date|date:"l j F" }}</strong>
|
|
||||||
{% if is_today %}<br><small>Today</small>{% endif %}
|
|
||||||
</span>
|
|
||||||
{% if is_today %}
|
|
||||||
<span></span>
|
|
||||||
{% else %}
|
|
||||||
<a href="?date={{ next_date|date:'Y-m-d' }}">Next →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="date" value="{{ day.date|date:'Y-m-d' }}">
|
|
||||||
|
|
||||||
<div class="osiris-card">
|
|
||||||
<h2 style="margin-top:0">Medication</h2>
|
|
||||||
{% regroup day.doses by time_of_day_label as dose_groups %}
|
|
||||||
{% for group in dose_groups %}
|
|
||||||
<div class="osiris-timeofday">{{ group.grouper }}</div>
|
|
||||||
{% for dose in group.list %}
|
|
||||||
<label class="osiris-dose {% if dose.taken %}is-taken{% endif %}">
|
|
||||||
<input type="checkbox" name="dose-{{ dose.id }}" {% if dose.taken %}checked{% endif %}>
|
|
||||||
<span>
|
|
||||||
<span class="dose-name">{{ dose.medication_name }}</span><br>
|
|
||||||
<span class="dose-detail">{{ dose.amount }}{% if dose.taken is None %} — not recorded{% endif %}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
{% empty %}
|
|
||||||
<p class="osiris-empty">
|
|
||||||
No active medication yet.
|
|
||||||
<a href="{% url 'admin:tracker_medication_add' %}">Add one</a>.
|
|
||||||
</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="osiris-card osiris-pulse">
|
|
||||||
<h2 style="margin-top:0">Pulse</h2>
|
|
||||||
<label for="bpm" class="dose-detail">Beats per minute (leave empty to clear)</label>
|
|
||||||
<input type="number" id="bpm" name="bpm" inputmode="numeric" min="20" max="400"
|
|
||||||
placeholder="e.g. 180" value="{{ day.pulse.bpm|default_if_none:'' }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="osiris-card osiris-note">
|
|
||||||
<h2 style="margin-top:0">Note for the day</h2>
|
|
||||||
<label for="note" class="dose-detail">Appetite, behaviour, anything worth remembering (leave empty to clear)</label>
|
|
||||||
<textarea id="note" name="note" placeholder="e.g. Ate well, more playful than yesterday">{{ day.note.body|default:'' }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="osiris-save">Save {{ day.date|date:"D j M" }}</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" style="margin-top:16px">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="date" value="{{ day.date|date:'Y-m-d' }}">
|
|
||||||
<div class="osiris-card osiris-note">
|
|
||||||
<h2 style="margin-top:0">Shared note</h2>
|
|
||||||
<label for="global_note" class="dose-detail">Instructions and things to do — one note for everyone, on every day</label>
|
|
||||||
<textarea id="global_note" name="global_note" placeholder="e.g. Vet appointment Friday — bring the pulse log">{{ global_note.body }}</textarea>
|
|
||||||
{% if global_note.body %}
|
|
||||||
<p class="dose-detail">Last updated {{ global_note.updated_at|date:"D j M, H:i" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="osiris-save">Save shared note</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="osiris-card" id="reminders" style="margin-top:16px">
|
|
||||||
<h2 style="margin-top:0">Reminders</h2>
|
|
||||||
<p class="dose-detail" id="reminder-status">Checking…</p>
|
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap">
|
|
||||||
<button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px" hidden></button>
|
|
||||||
<button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden>
|
|
||||||
Send test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="dose-detail">
|
|
||||||
A reminder is sent at each dose's reminder time, and only if it isn't already ticked off.
|
|
||||||
Set those times on the medication in the admin.
|
|
||||||
</p>
|
|
||||||
<details class="osiris-debug">
|
|
||||||
<summary class="dose-detail" style="cursor:pointer">Diagnostics</summary>
|
|
||||||
<ul id="reminder-checks"></ul>
|
|
||||||
<ul>
|
|
||||||
<li class="dose-detail">
|
|
||||||
{% if doses_with_reminders %}✅{% else %}❌{% endif %}
|
|
||||||
Doses with a reminder time: {{ doses_with_reminders }} of {{ day.doses|length }}
|
|
||||||
{% if not doses_with_reminders %}— set one on the medication in the admin, or nothing will ever fire{% endif %}
|
|
||||||
</li>
|
|
||||||
<li class="dose-detail">
|
|
||||||
{% if subscribed_devices %}✅{% else %}❌{% endif %}
|
|
||||||
Devices subscribed (this account): {{ subscribed_devices }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="text-align:center">
|
|
||||||
<a href="{% url 'tracker:recap' %}">Recap: doses, pulse & notes →</a><br>
|
|
||||||
<a href="{% url 'admin:index' %}">Manage medication & history →</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
|
|
||||||
const status = document.getElementById("reminder-status");
|
|
||||||
const toggle = document.getElementById("reminder-toggle");
|
|
||||||
const test = document.getElementById("reminder-test");
|
|
||||||
const checks = document.getElementById("reminder-checks");
|
|
||||||
|
|
||||||
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
|
||||||
|
|
||||||
// Every precondition for Web Push, each one reported by name. Push fails silently
|
|
||||||
// in a dozen ways; the point here is to say which one, rather than hide the card.
|
|
||||||
function preconditions() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "Server VAPID keys",
|
|
||||||
ok: Boolean(VAPID_PUBLIC_KEY),
|
|
||||||
detail: VAPID_PUBLIC_KEY
|
|
||||||
? VAPID_PUBLIC_KEY.slice(0, 12) + "…"
|
|
||||||
: "not set — run: manage.py generate_vapid_keys, then set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in the server environment and restart",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Secure context (HTTPS)",
|
|
||||||
ok: window.isSecureContext,
|
|
||||||
detail: window.isSecureContext
|
|
||||||
? location.origin
|
|
||||||
: location.origin + " — push needs https:// (or localhost)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Service worker support",
|
|
||||||
ok: "serviceWorker" in navigator,
|
|
||||||
detail: "serviceWorker" in navigator ? "supported" : "not supported by this browser",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Push support",
|
|
||||||
ok: "PushManager" in window,
|
|
||||||
detail:
|
|
||||||
"PushManager" in window
|
|
||||||
? "supported"
|
|
||||||
: "not supported — on Android use Chrome; Firefox in a private window will not do",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Notification permission",
|
|
||||||
ok: typeof Notification !== "undefined" && Notification.permission !== "denied",
|
|
||||||
detail:
|
|
||||||
typeof Notification === "undefined"
|
|
||||||
? "Notification API missing"
|
|
||||||
: Notification.permission,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChecks(extra) {
|
|
||||||
const rows = preconditions().concat(extra || []);
|
|
||||||
checks.innerHTML = "";
|
|
||||||
for (const row of rows) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "dose-detail";
|
|
||||||
li.textContent = `${row.ok ? "✅" : "❌"} ${row.name}: ${row.detail}`;
|
|
||||||
checks.appendChild(li);
|
|
||||||
}
|
|
||||||
return rows.every((row) => row.ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocking = preconditions().filter((row) => !row.ok);
|
|
||||||
if (blocking.length) {
|
|
||||||
// Notification.permission === "denied" is recoverable, so it is not fatal here;
|
|
||||||
// anything else means the button would do nothing at all.
|
|
||||||
const fatal = blocking.filter((row) => row.name !== "Notification permission");
|
|
||||||
if (fatal.length) {
|
|
||||||
status.textContent = `Reminders unavailable — ${fatal[0].name}: ${fatal[0].detail}`;
|
|
||||||
renderChecks();
|
|
||||||
document.querySelector(".osiris-debug").open = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function post(url, body) {
|
|
||||||
return fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", "X-CSRFToken": csrf },
|
|
||||||
body: JSON.stringify(body || {}),
|
|
||||||
}).then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(detail.detail || `Request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The browser wants the key as a Uint8Array, not the base64url we store it as.
|
|
||||||
function decodeKey(key) {
|
|
||||||
const padded = (key + "=".repeat((4 - (key.length % 4)) % 4))
|
|
||||||
.replace(/-/g, "+")
|
|
||||||
.replace(/_/g, "/");
|
|
||||||
const raw = atob(padded);
|
|
||||||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let subscription = null;
|
|
||||||
let worker = null;
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
renderChecks([
|
|
||||||
{
|
|
||||||
name: "Service worker",
|
|
||||||
ok: Boolean(worker),
|
|
||||||
detail: worker ? "active at " + worker.scope : "not registered yet — reload the page",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "This device subscribed",
|
|
||||||
ok: Boolean(subscription),
|
|
||||||
detail: subscription ? subscription.endpoint.slice(0, 48) + "…" : "no",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Installed as an app",
|
|
||||||
ok: window.matchMedia("(display-mode: standalone)").matches,
|
|
||||||
detail: window.matchMedia("(display-mode: standalone)").matches
|
|
||||||
? "yes"
|
|
||||||
: "no — works in the browser too, but Android delivers reliably once installed",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (Notification.permission === "denied") {
|
|
||||||
status.textContent =
|
|
||||||
"Notifications are blocked for this site. Re-allow them in Chrome → site settings → Notifications.";
|
|
||||||
toggle.hidden = true;
|
|
||||||
test.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle.hidden = false;
|
|
||||||
toggle.disabled = false;
|
|
||||||
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
|
|
||||||
test.hidden = !subscription;
|
|
||||||
status.textContent = subscription
|
|
||||||
? "Reminders are on for this device."
|
|
||||||
: "Reminders are off for this device.";
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.serviceWorker.ready
|
|
||||||
.then((registration) => {
|
|
||||||
worker = registration;
|
|
||||||
return registration.pushManager.getSubscription();
|
|
||||||
})
|
|
||||||
.then((existing) => {
|
|
||||||
subscription = existing;
|
|
||||||
render();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
status.textContent = "Service worker failed: " + error.message;
|
|
||||||
renderChecks();
|
|
||||||
document.querySelector(".osiris-debug").open = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
toggle.addEventListener("click", async () => {
|
|
||||||
toggle.disabled = true;
|
|
||||||
try {
|
|
||||||
if (subscription) {
|
|
||||||
await post("/api/push/unsubscribe", { endpoint: subscription.endpoint });
|
|
||||||
await subscription.unsubscribe();
|
|
||||||
subscription = null;
|
|
||||||
} else {
|
|
||||||
const permission = await Notification.requestPermission();
|
|
||||||
if (permission !== "granted") return render();
|
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: decodeKey(VAPID_PUBLIC_KEY),
|
|
||||||
});
|
|
||||||
await post("/api/push/subscribe", subscription.toJSON());
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error.message;
|
|
||||||
toggle.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.addEventListener("click", async () => {
|
|
||||||
test.disabled = true;
|
|
||||||
status.textContent = "Sending…";
|
|
||||||
try {
|
|
||||||
const result = await post("/api/push/test");
|
|
||||||
status.textContent = result.detail;
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error.message;
|
|
||||||
}
|
|
||||||
test.disabled = false;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@ -283,6 +283,12 @@ def log_dose(request: HttpRequest, payload: DoseLogIn):
|
|||||||
# --- Notes -----------------------------------------------------------------
|
# --- Notes -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notes/daily", response=list[DailyNoteOut])
|
||||||
|
def list_daily_notes(request: HttpRequest):
|
||||||
|
"""The whole journal, newest first."""
|
||||||
|
return DailyNote.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/notes/daily", response=DayOut)
|
@router.post("/notes/daily", response=DayOut)
|
||||||
def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
|
def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
|
||||||
"""The day's journal entry. One per day; an empty body removes it."""
|
"""The day's journal entry. One per day; an empty body removes it."""
|
||||||
|
|||||||
180
tracker/tests.py
180
tracker/tests.py
@ -248,117 +248,55 @@ class NoteApiTests(TrackerTestCase):
|
|||||||
self.assertEqual(GlobalNote.objects.count(), 1)
|
self.assertEqual(GlobalNote.objects.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class TodayPageTests(TrackerTestCase):
|
class AppShellTests(TrackerTestCase):
|
||||||
def test_the_page_requires_login(self):
|
"""The pages are one Vue SPA; Django only serves the shell and its config."""
|
||||||
response = self.client.get(reverse("tracker:today"))
|
|
||||||
|
def test_the_pages_require_login(self):
|
||||||
|
for name in ("tracker:today", "tracker:recap"):
|
||||||
|
response = self.client.get(reverse(name))
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
self.assertIn("/admin/login/", response["Location"])
|
||||||
|
|
||||||
def test_the_page_lists_the_doses(self):
|
def test_both_routes_serve_the_spa_shell(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(reverse("tracker:today"))
|
for name in ("tracker:today", "tracker:recap"):
|
||||||
|
response = self.client.get(reverse(name))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Vetmedin")
|
self.assertContains(response, 'id="app"')
|
||||||
self.assertContains(response, "Fortekor")
|
self.assertContains(response, "osiris-config")
|
||||||
self.assertContains(response, "Morning")
|
|
||||||
self.assertContains(response, "Evening")
|
|
||||||
# Nothing ticked yet, so every dose reads as unrecorded rather than missed.
|
|
||||||
self.assertContains(response, "not recorded", count=3)
|
|
||||||
self.assertNotContains(response, "checked")
|
|
||||||
|
|
||||||
def test_saving_ticks_doses_and_records_the_pulse(self):
|
def test_the_shell_sets_the_csrf_cookie_for_the_api(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
today = timezone.localdate()
|
response = self.client.get(reverse("tracker:today"))
|
||||||
|
|
||||||
response = self.client.post(
|
self.assertIn("csrftoken", response.cookies)
|
||||||
reverse("tracker:today"),
|
self.assertContains(response, "csrfToken")
|
||||||
{
|
|
||||||
"date": today.isoformat(),
|
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
|
||||||
f"dose-{self.morning.id}": "on",
|
def test_the_config_carries_the_reminder_diagnostics(self):
|
||||||
f"dose-{self.morning_only.id}": "on",
|
self.morning.reminder_time = "08:00"
|
||||||
# The evening checkbox is absent: not given.
|
self.morning.save()
|
||||||
"bpm": "188",
|
PushSubscription.objects.create(
|
||||||
},
|
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning).taken)
|
|
||||||
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning_only).taken)
|
|
||||||
self.assertFalse(DoseLog.objects.get(scheduled_dose=self.evening).taken)
|
|
||||||
self.assertEqual(PulseReading.objects.get(date=today).bpm, 188)
|
|
||||||
|
|
||||||
def test_clearing_the_pulse_field_removes_the_reading(self):
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
today = timezone.localdate()
|
|
||||||
PulseReading.objects.create(date=today, bpm=200)
|
|
||||||
|
|
||||||
self.client.post(
|
|
||||||
reverse("tracker:today"), {"date": today.isoformat(), "bpm": ""}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(PulseReading.objects.filter(date=today).exists())
|
|
||||||
|
|
||||||
def test_saving_records_the_daily_note(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
today = timezone.localdate()
|
|
||||||
|
|
||||||
self.client.post(
|
|
||||||
reverse("tracker:today"),
|
|
||||||
{"date": today.isoformat(), "note": "Ate well."},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(DailyNote.objects.get(date=today).body, "Ate well.")
|
|
||||||
|
|
||||||
def test_clearing_the_note_field_removes_the_note(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
today = timezone.localdate()
|
|
||||||
DailyNote.objects.create(date=today, body="Old news.")
|
|
||||||
|
|
||||||
self.client.post(reverse("tracker:today"), {"date": today.isoformat()})
|
|
||||||
|
|
||||||
self.assertFalse(DailyNote.objects.filter(date=today).exists())
|
|
||||||
|
|
||||||
def test_the_shared_note_form_saves_without_touching_the_day(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
today = timezone.localdate()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("tracker:today"),
|
|
||||||
{"date": today.isoformat(), "global_note": "Vet on Friday."},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
|
||||||
self.assertEqual(GlobalNote.load().body, "Vet on Friday.")
|
|
||||||
# Its own form carries no dose or pulse fields; the day must stay untouched.
|
|
||||||
self.assertFalse(DoseLog.objects.exists())
|
|
||||||
self.assertFalse(PulseReading.objects.exists())
|
|
||||||
self.assertFalse(DailyNote.objects.exists())
|
|
||||||
|
|
||||||
def test_the_page_shows_both_notes(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
DailyNote.objects.create(body="More playful than yesterday.")
|
|
||||||
note = GlobalNote.load()
|
|
||||||
note.body = "Vet on Friday."
|
|
||||||
note.save()
|
|
||||||
|
|
||||||
response = self.client.get(reverse("tracker:today"))
|
response = self.client.get(reverse("tracker:today"))
|
||||||
|
|
||||||
self.assertContains(response, "More playful than yesterday.")
|
self.assertContains(response, "BMn-test-public-key")
|
||||||
self.assertContains(response, "Vet on Friday.")
|
self.assertContains(response, '"activeDoses": 3')
|
||||||
|
self.assertContains(response, '"dosesWithReminders": 1')
|
||||||
|
self.assertContains(response, '"subscribedDevices": 1')
|
||||||
|
|
||||||
def test_the_page_links_to_the_recap(self):
|
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
|
||||||
|
def test_missing_vapid_keys_reach_the_frontend_as_an_empty_string(self):
|
||||||
|
# The frontend's diagnostics card explains the situation to the user.
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(reverse("tracker:today"))
|
response = self.client.get(reverse("tracker:today"))
|
||||||
self.assertContains(response, reverse("tracker:recap"))
|
self.assertContains(response, '"vapidPublicKey": ""')
|
||||||
|
|
||||||
|
|
||||||
class RecapTests(TrackerTestCase):
|
class RecapTests(TrackerTestCase):
|
||||||
def test_the_page_requires_login(self):
|
|
||||||
response = self.client.get(reverse("tracker:recap"))
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertIn("/admin/login/", response["Location"])
|
|
||||||
|
|
||||||
def test_a_fully_ticked_day_reads_as_complete(self):
|
def test_a_fully_ticked_day_reads_as_complete(self):
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
for dose in (self.morning, self.morning_only, self.evening):
|
for dose in (self.morning, self.morning_only, self.evening):
|
||||||
@ -393,27 +331,19 @@ class RecapTests(TrackerTestCase):
|
|||||||
self.assertEqual(recap[1]["note"], "Ate well.")
|
self.assertEqual(recap[1]["note"], "Ate well.")
|
||||||
self.assertIsNone(recap[0]["bpm"])
|
self.assertIsNone(recap[0]["bpm"])
|
||||||
|
|
||||||
def test_the_page_shows_the_table_graph_and_notes(self):
|
def test_the_recap_lists_every_daily_note(self):
|
||||||
self.client.force_login(self.user)
|
DailyNote.objects.create(body="Today's entry.")
|
||||||
today = timezone.localdate()
|
DailyNote.objects.create(
|
||||||
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170)
|
date=timezone.localdate() - timedelta(days=30), body="A month ago."
|
||||||
PulseReading.objects.create(date=today, bpm=190)
|
)
|
||||||
DailyNote.objects.create(date=today, body="More playful than yesterday.")
|
|
||||||
for dose in (self.morning, self.morning_only, self.evening):
|
|
||||||
DoseLog.objects.create(scheduled_dose=dose, date=today, taken=True)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("tracker:recap"))
|
response = self.client.get(
|
||||||
|
"/api/tracker/notes/daily", **self.auth_header()
|
||||||
|
).json()
|
||||||
|
|
||||||
self.assertContains(response, "✅")
|
self.assertEqual(
|
||||||
self.assertContains(response, "0/3") # every earlier day is incomplete
|
[n["body"] for n in response], ["Today's entry.", "A month ago."]
|
||||||
self.assertContains(response, "<polyline")
|
)
|
||||||
self.assertContains(response, "180.0 bpm") # average
|
|
||||||
self.assertContains(response, "More playful than yesterday.")
|
|
||||||
|
|
||||||
def test_the_graph_explains_itself_without_readings(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("tracker:recap"))
|
|
||||||
self.assertContains(response, "at least two days")
|
|
||||||
|
|
||||||
|
|
||||||
class PwaTests(TrackerTestCase):
|
class PwaTests(TrackerTestCase):
|
||||||
@ -431,34 +361,6 @@ class PwaTests(TrackerTestCase):
|
|||||||
self.assertContains(response, 'rel="manifest"')
|
self.assertContains(response, 'rel="manifest"')
|
||||||
self.assertContains(response, "serviceWorker.register")
|
self.assertContains(response, "serviceWorker.register")
|
||||||
|
|
||||||
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
|
|
||||||
def test_the_reminder_card_explains_itself_when_keys_are_missing(self):
|
|
||||||
# It used to hide entirely, which is indistinguishable from a broken page.
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
response = self.client.get(reverse("tracker:today"))
|
|
||||||
|
|
||||||
self.assertContains(response, "Reminders")
|
|
||||||
self.assertContains(response, "Diagnostics")
|
|
||||||
self.assertContains(response, "generate_vapid_keys")
|
|
||||||
|
|
||||||
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
|
|
||||||
def test_the_diagnostics_report_reminder_times_and_devices(self):
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("tracker:today"))
|
|
||||||
self.assertContains(response, "Doses with a reminder time: 0 of 3")
|
|
||||||
self.assertContains(response, "Devices subscribed (this account): 0")
|
|
||||||
|
|
||||||
self.morning.reminder_time = "08:00"
|
|
||||||
self.morning.save()
|
|
||||||
PushSubscription.objects.create(
|
|
||||||
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("tracker:today"))
|
|
||||||
self.assertContains(response, "Doses with a reminder time: 1 of 3")
|
|
||||||
self.assertContains(response, "Devices subscribed (this account): 1")
|
|
||||||
|
|
||||||
def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
|
def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
|
||||||
response = self.client.get(reverse("manifest"))
|
response = self.client.get(reverse("manifest"))
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@ from tracker import views
|
|||||||
|
|
||||||
app_name = "tracker"
|
app_name = "tracker"
|
||||||
|
|
||||||
|
# Both routes serve the same SPA shell; Vue's router picks the view.
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("today/", views.today, name="today"),
|
path("today/", views.app, name="today"),
|
||||||
path("recap/", views.recap, name="recap"),
|
path("recap/", views.app, name="recap"),
|
||||||
]
|
]
|
||||||
|
|||||||
164
tracker/views.py
164
tracker/views.py
@ -1,148 +1,54 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.shortcuts import redirect, render
|
from django.contrib.staticfiles import finders
|
||||||
from django.urls import reverse
|
from django.http import FileResponse, Http404
|
||||||
from django.utils import timezone
|
from django.middleware.csrf import get_token
|
||||||
from django.utils.dateparse import parse_date
|
from django.shortcuts import render
|
||||||
|
|
||||||
from tracker.api import build_day, build_recap, pulse_trend
|
from tracker.models import ScheduledDose
|
||||||
from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
|
|
||||||
|
|
||||||
CHART_WIDTH = 320
|
|
||||||
CHART_HEIGHT = 140
|
|
||||||
CHART_PADDING = 24
|
|
||||||
|
|
||||||
|
|
||||||
def build_chart(readings: list[PulseReading]) -> Optional[dict]:
|
def service_worker(request):
|
||||||
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript."""
|
"""The built worker (frontend/src/sw.ts), from the root so its scope covers /admin/.
|
||||||
if len(readings) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
bpms = [r.bpm for r in readings]
|
Served by a view rather than the staticfiles machinery because the URL must
|
||||||
low, high = min(bpms), max(bpms)
|
stay stable and unhashed — a renamed worker would be a brand-new worker.
|
||||||
# A flat line would divide by zero, and a narrow range exaggerates noise.
|
"""
|
||||||
span = max(high - low, 10)
|
path = finders.find("sw.js")
|
||||||
mid = (high + low) / 2
|
if path is None:
|
||||||
low, high = mid - span / 2, mid + span / 2
|
raise Http404("Frontend not built yet — run: make frontend")
|
||||||
|
|
||||||
first_day = readings[0].date
|
response = FileResponse(open(path, "rb"), content_type="application/javascript")
|
||||||
total_days = max((readings[-1].date - first_day).days, 1)
|
# Browsers cap service-worker caching anyway; make updates immediate.
|
||||||
|
response["Cache-Control"] = "no-cache"
|
||||||
inner_width = CHART_WIDTH - 2 * CHART_PADDING
|
return response
|
||||||
inner_height = CHART_HEIGHT - 2 * CHART_PADDING
|
|
||||||
|
|
||||||
points = []
|
|
||||||
for reading in readings:
|
|
||||||
x = CHART_PADDING + (reading.date - first_day).days / total_days * inner_width
|
|
||||||
y = CHART_PADDING + (high - reading.bpm) / (high - low) * inner_height
|
|
||||||
points.append({"x": round(x, 1), "y": round(y, 1), "reading": reading})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"points": points,
|
|
||||||
"polyline": " ".join(f"{p['x']},{p['y']}" for p in points),
|
|
||||||
"low": round(low),
|
|
||||||
"high": round(high),
|
|
||||||
"average": round(sum(bpms) / len(bpms), 1),
|
|
||||||
"trend": pulse_trend(readings),
|
|
||||||
"width": CHART_WIDTH,
|
|
||||||
"height": CHART_HEIGHT,
|
|
||||||
"first_date": first_day,
|
|
||||||
"last_date": readings[-1].date,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def today(request):
|
def app(request):
|
||||||
"""The main screen: tick off the day's doses and record the pulse."""
|
"""The single-page app shell: Vue routes between Today and Recap client-side.
|
||||||
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
|
|
||||||
|
|
||||||
if request.method == "POST":
|
All data flows through the API; the shell only carries the per-page-load
|
||||||
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
|
facts the frontend cannot fetch (CSRF token) or that feed the reminder
|
||||||
|
diagnostics.
|
||||||
# The shared note has its own form and save button, so that editing it
|
"""
|
||||||
# while looking at a past day cannot touch that day's records.
|
active_doses = ScheduledDose.objects.filter(
|
||||||
if "global_note" in request.POST:
|
is_active=True, medication__is_active=True
|
||||||
note = GlobalNote.load()
|
|
||||||
note.body = request.POST["global_note"].strip()
|
|
||||||
note.save(update_fields=["body", "updated_at"])
|
|
||||||
messages.success(request, "Shared note saved.")
|
|
||||||
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
|
|
||||||
|
|
||||||
doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
|
|
||||||
|
|
||||||
for dose in doses:
|
|
||||||
taken = f"dose-{dose.id}" in request.POST
|
|
||||||
log, _ = DoseLog.objects.get_or_create(
|
|
||||||
scheduled_dose=dose, date=day, defaults={"taken": taken}
|
|
||||||
)
|
)
|
||||||
log.taken = taken
|
|
||||||
log.save()
|
|
||||||
|
|
||||||
bpm = (request.POST.get("bpm") or "").strip()
|
|
||||||
if bpm:
|
|
||||||
try:
|
|
||||||
PulseReading.objects.update_or_create(
|
|
||||||
date=day, defaults={"bpm": int(bpm)}
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
messages.error(request, f"'{bpm}' is not a valid pulse.")
|
|
||||||
else:
|
|
||||||
# Clearing the field removes the reading for that day.
|
|
||||||
PulseReading.objects.filter(date=day).delete()
|
|
||||||
|
|
||||||
note_body = (request.POST.get("note") or "").strip()
|
|
||||||
if note_body:
|
|
||||||
DailyNote.objects.update_or_create(date=day, defaults={"body": note_body})
|
|
||||||
else:
|
|
||||||
DailyNote.objects.filter(date=day).delete()
|
|
||||||
|
|
||||||
messages.success(request, f"Saved for {day:%A %d %B}.")
|
|
||||||
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
|
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"tracker/today.html",
|
"tracker/app.html",
|
||||||
{
|
{
|
||||||
"title": "Today",
|
"config": {
|
||||||
"day": build_day(day),
|
"vapidPublicKey": settings.VAPID_PUBLIC_KEY,
|
||||||
"is_today": day == timezone.localdate(),
|
# get_token also sets the CSRF cookie for the session.
|
||||||
"previous_date": day - timedelta(days=1),
|
"csrfToken": get_token(request),
|
||||||
"next_date": day + timedelta(days=1),
|
"activeDoses": active_doses.count(),
|
||||||
"global_note": GlobalNote.load(),
|
"dosesWithReminders": active_doses.filter(
|
||||||
# Empty when push is not configured; the card then says so rather than
|
reminder_time__isnull=False
|
||||||
# vanishing, because a missing card looks like a bug in the page.
|
|
||||||
"vapid_public_key": settings.VAPID_PUBLIC_KEY,
|
|
||||||
"doses_with_reminders": ScheduledDose.objects.filter(
|
|
||||||
is_active=True,
|
|
||||||
medication__is_active=True,
|
|
||||||
reminder_time__isnull=False,
|
|
||||||
).count(),
|
).count(),
|
||||||
"subscribed_devices": request.user.push_subscriptions.count(),
|
"subscribedDevices": request.user.push_subscriptions.count(),
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
|
||||||
def recap(request):
|
|
||||||
"""The overview: dose completeness per day, the pulse graph, and the journal."""
|
|
||||||
readings = list(
|
|
||||||
PulseReading.objects.filter(
|
|
||||||
date__gte=timezone.localdate() - timedelta(days=90)
|
|
||||||
).order_by("date")
|
|
||||||
)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"tracker/recap.html",
|
|
||||||
{
|
|
||||||
"title": "Recap",
|
|
||||||
"recap_days": build_recap(14),
|
|
||||||
"chart": build_chart(readings),
|
|
||||||
"reading_count": len(readings),
|
|
||||||
"daily_notes": DailyNote.objects.all(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
14
uv.lock
14
uv.lock
@ -377,6 +377,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-vite"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/fe/4be07f538bbf9bf8bf73b045552ec2a1b4fba67e3176abb30490b643368e/django_vite-3.1.0.tar.gz", hash = "sha256:8b4ffe4a9fa81ff568bfb195e74dde8694aaf13fd4b656ae60bf59cce08b85e8", size = 25434, upload-time = "2025-02-23T15:32:07.914Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/59/7df4b1077fa41b43b4e542696d75138dbb86a65f44248c607b3e0f1014ce/django_vite-3.1.0-py3-none-any.whl", hash = "sha256:4e46572bd6b1ce70784be129205dc2ffcbc7a3c19fea50bebfb72b327bbde5fc", size = 23579, upload-time = "2025-02-23T15:32:06.603Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "executing"
|
name = "executing"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@ -635,6 +647,7 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-ninja" },
|
{ name = "django-ninja" },
|
||||||
|
{ name = "django-vite" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "pywebpush" },
|
{ name = "pywebpush" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
@ -651,6 +664,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "django", specifier = ">=5.2" },
|
{ name = "django", specifier = ">=5.2" },
|
||||||
{ name = "django-ninja", specifier = ">=1.4" },
|
{ name = "django-ninja", specifier = ">=1.4" },
|
||||||
|
{ name = "django-vite", specifier = ">=3.1.0" },
|
||||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||||
{ name = "pywebpush", specifier = ">=2.3.0" },
|
{ name = "pywebpush", specifier = ">=2.3.0" },
|
||||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user