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/
|
||||
.env
|
||||
reminders.log
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
|
||||
23
Makefile
23
Makefile
@ -19,7 +19,7 @@ help: ## Show this help
|
||||
# --- Setup -----------------------------------------------------------------
|
||||
|
||||
.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
|
||||
@$(MAKE) --no-print-directory env # after sync: it needs Django importable
|
||||
$(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
|
||||
$(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 -----------------------------------------------------------
|
||||
|
||||
.PHONY: run
|
||||
@ -114,7 +133,7 @@ run: ## Run the development server
|
||||
$(MANAGE) runserver $(PORT)
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the test suite
|
||||
test: frontend/dist/.vite/manifest.json ## Run the test suite
|
||||
$(MANAGE) test
|
||||
|
||||
.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 sys
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
@ -52,6 +53,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_vite",
|
||||
"accounts",
|
||||
"tracker",
|
||||
]
|
||||
@ -136,8 +138,19 @@ USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static"
|
||||
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output.
|
||||
STATICFILES_DIRS = [BASE_DIR / "assets"]
|
||||
# PWA icons live in assets/; frontend/dist is the Vite build output. STATIC_ROOT
|
||||
# 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 = {
|
||||
"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_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ from django.urls import include, path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from osiris.api import api
|
||||
from tracker.views import service_worker
|
||||
|
||||
admin.site.site_header = "Osiris"
|
||||
admin.site.site_title = "Osiris"
|
||||
@ -13,13 +14,7 @@ urlpatterns = [
|
||||
path("api/", api.urls),
|
||||
# Must be served from the root: a worker under /static/ could only control
|
||||
# /static/, not the /admin/ pages it exists to cache.
|
||||
path(
|
||||
"sw.js",
|
||||
TemplateView.as_view(
|
||||
template_name="sw.js", content_type="application/javascript"
|
||||
),
|
||||
name="service-worker",
|
||||
),
|
||||
path("sw.js", service_worker, name="service-worker"),
|
||||
# A template, not a static file, so {% static %} resolves the icons to their
|
||||
# hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
|
||||
path(
|
||||
|
||||
@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"django>=5.2",
|
||||
"django-ninja>=1.4",
|
||||
"django-vite>=3.1.0",
|
||||
"gunicorn>=26.0.0",
|
||||
"pywebpush>=2.3.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 -----------------------------------------------------------------
|
||||
|
||||
|
||||
@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)
|
||||
def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
|
||||
"""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)
|
||||
|
||||
|
||||
class TodayPageTests(TrackerTestCase):
|
||||
def test_the_page_requires_login(self):
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
class AppShellTests(TrackerTestCase):
|
||||
"""The pages are one Vue SPA; Django only serves the shell and its config."""
|
||||
|
||||
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.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)
|
||||
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.assertContains(response, "Vetmedin")
|
||||
self.assertContains(response, "Fortekor")
|
||||
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")
|
||||
self.assertContains(response, 'id="app"')
|
||||
self.assertContains(response, "osiris-config")
|
||||
|
||||
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)
|
||||
today = timezone.localdate()
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
|
||||
response = self.client.post(
|
||||
reverse("tracker:today"),
|
||||
{
|
||||
"date": today.isoformat(),
|
||||
f"dose-{self.morning.id}": "on",
|
||||
f"dose-{self.morning_only.id}": "on",
|
||||
# The evening checkbox is absent: not given.
|
||||
"bpm": "188",
|
||||
},
|
||||
self.assertIn("csrftoken", response.cookies)
|
||||
self.assertContains(response, "csrfToken")
|
||||
|
||||
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
|
||||
def test_the_config_carries_the_reminder_diagnostics(self):
|
||||
self.morning.reminder_time = "08:00"
|
||||
self.morning.save()
|
||||
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)
|
||||
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"))
|
||||
|
||||
self.assertContains(response, "More playful than yesterday.")
|
||||
self.assertContains(response, "Vet on Friday.")
|
||||
self.assertContains(response, "BMn-test-public-key")
|
||||
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)
|
||||
response = self.client.get(reverse("tracker:today"))
|
||||
self.assertContains(response, reverse("tracker:recap"))
|
||||
self.assertContains(response, '"vapidPublicKey": ""')
|
||||
|
||||
|
||||
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):
|
||||
today = timezone.localdate()
|
||||
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.assertIsNone(recap[0]["bpm"])
|
||||
|
||||
def test_the_page_shows_the_table_graph_and_notes(self):
|
||||
self.client.force_login(self.user)
|
||||
today = timezone.localdate()
|
||||
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170)
|
||||
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)
|
||||
def test_the_recap_lists_every_daily_note(self):
|
||||
DailyNote.objects.create(body="Today's entry.")
|
||||
DailyNote.objects.create(
|
||||
date=timezone.localdate() - timedelta(days=30), body="A month ago."
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("tracker:recap"))
|
||||
response = self.client.get(
|
||||
"/api/tracker/notes/daily", **self.auth_header()
|
||||
).json()
|
||||
|
||||
self.assertContains(response, "✅")
|
||||
self.assertContains(response, "0/3") # every earlier day is incomplete
|
||||
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")
|
||||
self.assertEqual(
|
||||
[n["body"] for n in response], ["Today's entry.", "A month ago."]
|
||||
)
|
||||
|
||||
|
||||
class PwaTests(TrackerTestCase):
|
||||
@ -431,34 +361,6 @@ class PwaTests(TrackerTestCase):
|
||||
self.assertContains(response, 'rel="manifest"')
|
||||
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):
|
||||
response = self.client.get(reverse("manifest"))
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@ from tracker import views
|
||||
|
||||
app_name = "tracker"
|
||||
|
||||
# Both routes serve the same SPA shell; Vue's router picks the view.
|
||||
urlpatterns = [
|
||||
path("today/", views.today, name="today"),
|
||||
path("recap/", views.recap, name="recap"),
|
||||
path("today/", views.app, name="today"),
|
||||
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.contrib import messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.http import FileResponse, Http404
|
||||
from django.middleware.csrf import get_token
|
||||
from django.shortcuts import render
|
||||
|
||||
from tracker.api import build_day, build_recap, pulse_trend
|
||||
from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
|
||||
|
||||
CHART_WIDTH = 320
|
||||
CHART_HEIGHT = 140
|
||||
CHART_PADDING = 24
|
||||
from tracker.models import ScheduledDose
|
||||
|
||||
|
||||
def build_chart(readings: list[PulseReading]) -> Optional[dict]:
|
||||
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript."""
|
||||
if len(readings) < 2:
|
||||
return None
|
||||
def service_worker(request):
|
||||
"""The built worker (frontend/src/sw.ts), from the root so its scope covers /admin/.
|
||||
|
||||
bpms = [r.bpm for r in readings]
|
||||
low, high = min(bpms), max(bpms)
|
||||
# A flat line would divide by zero, and a narrow range exaggerates noise.
|
||||
span = max(high - low, 10)
|
||||
mid = (high + low) / 2
|
||||
low, high = mid - span / 2, mid + span / 2
|
||||
Served by a view rather than the staticfiles machinery because the URL must
|
||||
stay stable and unhashed — a renamed worker would be a brand-new worker.
|
||||
"""
|
||||
path = finders.find("sw.js")
|
||||
if path is None:
|
||||
raise Http404("Frontend not built yet — run: make frontend")
|
||||
|
||||
first_day = readings[0].date
|
||||
total_days = max((readings[-1].date - first_day).days, 1)
|
||||
|
||||
inner_width = CHART_WIDTH - 2 * CHART_PADDING
|
||||
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,
|
||||
}
|
||||
response = FileResponse(open(path, "rb"), content_type="application/javascript")
|
||||
# Browsers cap service-worker caching anyway; make updates immediate.
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def today(request):
|
||||
"""The main screen: tick off the day's doses and record the pulse."""
|
||||
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
|
||||
def app(request):
|
||||
"""The single-page app shell: Vue routes between Today and Recap client-side.
|
||||
|
||||
if request.method == "POST":
|
||||
day = parse_date(request.POST.get("date", "")) or timezone.localdate()
|
||||
|
||||
# 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.
|
||||
if "global_note" in request.POST:
|
||||
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}
|
||||
All data flows through the API; the shell only carries the per-page-load
|
||||
facts the frontend cannot fetch (CSRF token) or that feed the reminder
|
||||
diagnostics.
|
||||
"""
|
||||
active_doses = ScheduledDose.objects.filter(
|
||||
is_active=True, medication__is_active=True
|
||||
)
|
||||
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(
|
||||
request,
|
||||
"tracker/today.html",
|
||||
"tracker/app.html",
|
||||
{
|
||||
"title": "Today",
|
||||
"day": build_day(day),
|
||||
"is_today": day == timezone.localdate(),
|
||||
"previous_date": day - timedelta(days=1),
|
||||
"next_date": day + timedelta(days=1),
|
||||
"global_note": GlobalNote.load(),
|
||||
# Empty when push is not configured; the card then says so rather than
|
||||
# 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,
|
||||
"config": {
|
||||
"vapidPublicKey": settings.VAPID_PUBLIC_KEY,
|
||||
# get_token also sets the CSRF cookie for the session.
|
||||
"csrfToken": get_token(request),
|
||||
"activeDoses": active_doses.count(),
|
||||
"dosesWithReminders": active_doses.filter(
|
||||
reminder_time__isnull=False
|
||||
).count(),
|
||||
"subscribed_devices": 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(),
|
||||
"subscribedDevices": request.user.push_subscriptions.count(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "executing"
|
||||
version = "2.2.1"
|
||||
@ -635,6 +647,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-ninja" },
|
||||
{ name = "django-vite" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pywebpush" },
|
||||
{ name = "whitenoise" },
|
||||
@ -651,6 +664,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=5.2" },
|
||||
{ name = "django-ninja", specifier = ">=1.4" },
|
||||
{ name = "django-vite", specifier = ">=3.1.0" },
|
||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||
{ name = "pywebpush", specifier = ">=2.3.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user