feat: frontend using django-vite + blabla

This commit is contained in:
Loïc Gremaud 2026-07-17 17:47:03 +02:00
parent 63a5a69c67
commit c06b184a2d
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
48 changed files with 13896 additions and 885 deletions

3
.gitignore vendored
View File

@ -14,3 +14,6 @@ db.sqlite3
static/ static/
.env .env
reminders.log reminders.log
# Frontend
node_modules/

View File

@ -19,7 +19,7 @@ help: ## Show this help
# --- Setup ----------------------------------------------------------------- # --- Setup -----------------------------------------------------------------
.PHONY: install .PHONY: install
install: check-uv ## Install dependencies, create .env, migrate, collect static files install: check-uv frontend ## Install dependencies, build the frontend, migrate, collect static files
$(UV) sync $(UV) sync
@$(MAKE) --no-print-directory env # after sync: it needs Django importable @$(MAKE) --no-print-directory env # after sync: it needs Django importable
$(MANAGE) migrate $(MANAGE) migrate
@ -107,6 +107,25 @@ cron-remove: ## Remove the reminder cron job
cron-test: ## Run the reminder job once, reporting what it would send cron-test: ## Run the reminder job once, reporting what it would send
$(REMINDER_SH) --dry-run $(REMINDER_SH) --dry-run
# --- Frontend --------------------------------------------------------------
.PHONY: frontend
frontend: ## Install npm dependencies and build the Vue frontend
cd frontend && npm install && npm run build
# Only build when no bundle exists yet: tests render the shell template, which
# needs the Vite manifest, but should not pay for a rebuild on every run.
frontend/dist/.vite/manifest.json:
@$(MAKE) --no-print-directory frontend
.PHONY: front-dev
front-dev: ## Run the Vite dev server with hot reload (pair with: DJANGO_VITE_DEV=1 make run)
cd frontend && npm run dev
.PHONY: api-client
api-client: ## Regenerate the typed TS API client (needs 'make run' in another terminal)
cd frontend && npm run generate:api
# --- Development ----------------------------------------------------------- # --- Development -----------------------------------------------------------
.PHONY: run .PHONY: run
@ -114,7 +133,7 @@ run: ## Run the development server
$(MANAGE) runserver $(PORT) $(MANAGE) runserver $(PORT)
.PHONY: test .PHONY: test
test: ## Run the test suite test: frontend/dist/.vite/manifest.json ## Run the test suite
$(MANAGE) test $(MANAGE) test
.PHONY: lint .PHONY: lint

View 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

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

32
frontend/src/App.vue Normal file
View 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>

View 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>());

View 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;
};

View 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';

View 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;

View 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,
});

View 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;
};

View 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();
},
};

View 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;
};

View 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 arent 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;
};

View 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 };
};

View 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];
};

View 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;
}

View 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
View 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
});
};

View 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;
};

View 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>

View 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>

View 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
View 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
View 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);
}

View 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
View 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
View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}

102
frontend/src/sw.ts Normal file
View 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);
}),
);
});

View 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>

View 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
View 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
View 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
View 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",
},
},
});

View File

@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import os import os
import sys
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@ -52,6 +53,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django_vite",
"accounts", "accounts",
"tracker", "tracker",
] ]
@ -136,8 +138,19 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" STATIC_ROOT = BASE_DIR / "static"
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output. # PWA icons live in assets/; frontend/dist is the Vite build output. STATIC_ROOT
STATICFILES_DIRS = [BASE_DIR / "assets"] # is only the collectstatic destination.
STATICFILES_DIRS = [BASE_DIR / "assets", BASE_DIR / "frontend" / "dist"]
# The Vue frontend, served through django-vite's template tags. With
# DJANGO_VITE_DEV=1 the tags point at the Vite dev server (npm run dev) for hot
# reload; otherwise they resolve hashed files from the built manifest.
DJANGO_VITE = {
"default": {
"dev_mode": os.environ.get("DJANGO_VITE_DEV", "0") == "1",
"manifest_path": BASE_DIR / "frontend" / "dist" / ".vite" / "manifest.json",
}
}
STORAGES = { STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
@ -148,6 +161,13 @@ STORAGES = {
}, },
} }
if "test" in sys.argv:
# The manifest storage refuses to serve anything collectstatic hasn't seen,
# which would make every template that names a static file fail under test.
STORAGES["staticfiles"] = {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
}
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@ -4,6 +4,7 @@ from django.urls import include, path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from osiris.api import api from osiris.api import api
from tracker.views import service_worker
admin.site.site_header = "Osiris" admin.site.site_header = "Osiris"
admin.site.site_title = "Osiris" admin.site.site_title = "Osiris"
@ -13,13 +14,7 @@ urlpatterns = [
path("api/", api.urls), path("api/", api.urls),
# Must be served from the root: a worker under /static/ could only control # Must be served from the root: a worker under /static/ could only control
# /static/, not the /admin/ pages it exists to cache. # /static/, not the /admin/ pages it exists to cache.
path( path("sw.js", service_worker, name="service-worker"),
"sw.js",
TemplateView.as_view(
template_name="sw.js", content_type="application/javascript"
),
name="service-worker",
),
# A template, not a static file, so {% static %} resolves the icons to their # A template, not a static file, so {% static %} resolves the icons to their
# hashed names — .webmanifest is not URL-rewritten by the staticfiles storage. # hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
path( path(

View File

@ -7,6 +7,7 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"django>=5.2", "django>=5.2",
"django-ninja>=1.4", "django-ninja>=1.4",
"django-vite>=3.1.0",
"gunicorn>=26.0.0", "gunicorn>=26.0.0",
"pywebpush>=2.3.0", "pywebpush>=2.3.0",
"whitenoise>=6.12.0", "whitenoise>=6.12.0",

View File

@ -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" } }
)
);
})
);
});

View 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>

View File

@ -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 %}&uarr; +{{ chart.trend }}{% elif chart.trend < 0 %}&darr; {{ chart.trend }}{% else %}&rarr; 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' %}">&larr; Back to today</a></p>
{% endblock %}

View File

@ -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' }}">&larr; 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 &rarr;</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 &amp; notes &rarr;</a><br>
<a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</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 %}

View File

@ -283,6 +283,12 @@ def log_dose(request: HttpRequest, payload: DoseLogIn):
# --- Notes ----------------------------------------------------------------- # --- Notes -----------------------------------------------------------------
@router.get("/notes/daily", response=list[DailyNoteOut])
def list_daily_notes(request: HttpRequest):
"""The whole journal, newest first."""
return DailyNote.objects.all()
@router.post("/notes/daily", response=DayOut) @router.post("/notes/daily", response=DayOut)
def save_daily_note(request: HttpRequest, payload: DailyNoteIn): def save_daily_note(request: HttpRequest, payload: DailyNoteIn):
"""The day's journal entry. One per day; an empty body removes it.""" """The day's journal entry. One per day; an empty body removes it."""

View File

@ -248,117 +248,55 @@ class NoteApiTests(TrackerTestCase):
self.assertEqual(GlobalNote.objects.count(), 1) self.assertEqual(GlobalNote.objects.count(), 1)
class TodayPageTests(TrackerTestCase): class AppShellTests(TrackerTestCase):
def test_the_page_requires_login(self): """The pages are one Vue SPA; Django only serves the shell and its config."""
response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_the_page_lists_the_doses(self): 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_both_routes_serve_the_spa_shell(self):
self.client.force_login(self.user)
for name in ("tracker:today", "tracker:recap"):
response = self.client.get(reverse(name))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="app"')
self.assertContains(response, "osiris-config")
def test_the_shell_sets_the_csrf_cookie_for_the_api(self):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today")) response = self.client.get(reverse("tracker:today"))
self.assertEqual(response.status_code, 200) self.assertIn("csrftoken", response.cookies)
self.assertContains(response, "Vetmedin") self.assertContains(response, "csrfToken")
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")
def test_saving_ticks_doses_and_records_the_pulse(self): @override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
self.client.force_login(self.user) def test_the_config_carries_the_reminder_diagnostics(self):
today = timezone.localdate() self.morning.reminder_time = "08:00"
self.morning.save()
response = self.client.post( PushSubscription.objects.create(
reverse("tracker:today"), user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
{
"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.assertEqual(response.status_code, 302)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning).taken)
self.assertTrue(DoseLog.objects.get(scheduled_dose=self.morning_only).taken)
self.assertFalse(DoseLog.objects.get(scheduled_dose=self.evening).taken)
self.assertEqual(PulseReading.objects.get(date=today).bpm, 188)
def test_clearing_the_pulse_field_removes_the_reading(self):
self.client.force_login(self.user) self.client.force_login(self.user)
today = timezone.localdate()
PulseReading.objects.create(date=today, bpm=200)
self.client.post(
reverse("tracker:today"), {"date": today.isoformat(), "bpm": ""}
)
self.assertFalse(PulseReading.objects.filter(date=today).exists())
def test_saving_records_the_daily_note(self):
self.client.force_login(self.user)
today = timezone.localdate()
self.client.post(
reverse("tracker:today"),
{"date": today.isoformat(), "note": "Ate well."},
)
self.assertEqual(DailyNote.objects.get(date=today).body, "Ate well.")
def test_clearing_the_note_field_removes_the_note(self):
self.client.force_login(self.user)
today = timezone.localdate()
DailyNote.objects.create(date=today, body="Old news.")
self.client.post(reverse("tracker:today"), {"date": today.isoformat()})
self.assertFalse(DailyNote.objects.filter(date=today).exists())
def test_the_shared_note_form_saves_without_touching_the_day(self):
self.client.force_login(self.user)
today = timezone.localdate()
response = self.client.post(
reverse("tracker:today"),
{"date": today.isoformat(), "global_note": "Vet on Friday."},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(GlobalNote.load().body, "Vet on Friday.")
# Its own form carries no dose or pulse fields; the day must stay untouched.
self.assertFalse(DoseLog.objects.exists())
self.assertFalse(PulseReading.objects.exists())
self.assertFalse(DailyNote.objects.exists())
def test_the_page_shows_both_notes(self):
self.client.force_login(self.user)
DailyNote.objects.create(body="More playful than yesterday.")
note = GlobalNote.load()
note.body = "Vet on Friday."
note.save()
response = self.client.get(reverse("tracker:today")) response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "More playful than yesterday.") self.assertContains(response, "BMn-test-public-key")
self.assertContains(response, "Vet on Friday.") self.assertContains(response, '"activeDoses": 3')
self.assertContains(response, '"dosesWithReminders": 1')
self.assertContains(response, '"subscribedDevices": 1')
def test_the_page_links_to_the_recap(self): @override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
def test_missing_vapid_keys_reach_the_frontend_as_an_empty_string(self):
# The frontend's diagnostics card explains the situation to the user.
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today")) response = self.client.get(reverse("tracker:today"))
self.assertContains(response, reverse("tracker:recap")) self.assertContains(response, '"vapidPublicKey": ""')
class RecapTests(TrackerTestCase): class RecapTests(TrackerTestCase):
def test_the_page_requires_login(self):
response = self.client.get(reverse("tracker:recap"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_a_fully_ticked_day_reads_as_complete(self): def test_a_fully_ticked_day_reads_as_complete(self):
today = timezone.localdate() today = timezone.localdate()
for dose in (self.morning, self.morning_only, self.evening): for dose in (self.morning, self.morning_only, self.evening):
@ -393,27 +331,19 @@ class RecapTests(TrackerTestCase):
self.assertEqual(recap[1]["note"], "Ate well.") self.assertEqual(recap[1]["note"], "Ate well.")
self.assertIsNone(recap[0]["bpm"]) self.assertIsNone(recap[0]["bpm"])
def test_the_page_shows_the_table_graph_and_notes(self): def test_the_recap_lists_every_daily_note(self):
self.client.force_login(self.user) DailyNote.objects.create(body="Today's entry.")
today = timezone.localdate() DailyNote.objects.create(
PulseReading.objects.create(date=today - timedelta(days=1), bpm=170) date=timezone.localdate() - timedelta(days=30), body="A month ago."
PulseReading.objects.create(date=today, bpm=190) )
DailyNote.objects.create(date=today, body="More playful than yesterday.")
for dose in (self.morning, self.morning_only, self.evening):
DoseLog.objects.create(scheduled_dose=dose, date=today, taken=True)
response = self.client.get(reverse("tracker:recap")) response = self.client.get(
"/api/tracker/notes/daily", **self.auth_header()
).json()
self.assertContains(response, "") self.assertEqual(
self.assertContains(response, "0/3") # every earlier day is incomplete [n["body"] for n in response], ["Today's entry.", "A month ago."]
self.assertContains(response, "<polyline") )
self.assertContains(response, "180.0 bpm") # average
self.assertContains(response, "More playful than yesterday.")
def test_the_graph_explains_itself_without_readings(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:recap"))
self.assertContains(response, "at least two days")
class PwaTests(TrackerTestCase): class PwaTests(TrackerTestCase):
@ -431,34 +361,6 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"') self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register") self.assertContains(response, "serviceWorker.register")
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
def test_the_reminder_card_explains_itself_when_keys_are_missing(self):
# It used to hide entirely, which is indistinguishable from a broken page.
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Reminders")
self.assertContains(response, "Diagnostics")
self.assertContains(response, "generate_vapid_keys")
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
def test_the_diagnostics_report_reminder_times_and_devices(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 0 of 3")
self.assertContains(response, "Devices subscribed (this account): 0")
self.morning.reminder_time = "08:00"
self.morning.save()
PushSubscription.objects.create(
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 1 of 3")
self.assertContains(response, "Devices subscribed (this account): 1")
def test_the_manifest_is_valid_json_pointing_at_real_icons(self): def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
response = self.client.get(reverse("manifest")) response = self.client.get(reverse("manifest"))

View File

@ -4,7 +4,8 @@ from tracker import views
app_name = "tracker" app_name = "tracker"
# Both routes serve the same SPA shell; Vue's router picks the view.
urlpatterns = [ urlpatterns = [
path("today/", views.today, name="today"), path("today/", views.app, name="today"),
path("recap/", views.recap, name="recap"), path("recap/", views.app, name="recap"),
] ]

View File

@ -1,148 +1,54 @@
from datetime import timedelta
from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import redirect, render from django.contrib.staticfiles import finders
from django.urls import reverse from django.http import FileResponse, Http404
from django.utils import timezone from django.middleware.csrf import get_token
from django.utils.dateparse import parse_date from django.shortcuts import render
from tracker.api import build_day, build_recap, pulse_trend from tracker.models import ScheduledDose
from tracker.models import DailyNote, DoseLog, GlobalNote, PulseReading, ScheduledDose
CHART_WIDTH = 320
CHART_HEIGHT = 140
CHART_PADDING = 24
def build_chart(readings: list[PulseReading]) -> Optional[dict]: def service_worker(request):
"""Lay the readings out as SVG coordinates, so the page needs no JavaScript.""" """The built worker (frontend/src/sw.ts), from the root so its scope covers /admin/.
if len(readings) < 2:
return None
bpms = [r.bpm for r in readings] Served by a view rather than the staticfiles machinery because the URL must
low, high = min(bpms), max(bpms) stay stable and unhashed a renamed worker would be a brand-new worker.
# A flat line would divide by zero, and a narrow range exaggerates noise. """
span = max(high - low, 10) path = finders.find("sw.js")
mid = (high + low) / 2 if path is None:
low, high = mid - span / 2, mid + span / 2 raise Http404("Frontend not built yet — run: make frontend")
first_day = readings[0].date response = FileResponse(open(path, "rb"), content_type="application/javascript")
total_days = max((readings[-1].date - first_day).days, 1) # Browsers cap service-worker caching anyway; make updates immediate.
response["Cache-Control"] = "no-cache"
inner_width = CHART_WIDTH - 2 * CHART_PADDING return response
inner_height = CHART_HEIGHT - 2 * CHART_PADDING
points = []
for reading in readings:
x = CHART_PADDING + (reading.date - first_day).days / total_days * inner_width
y = CHART_PADDING + (high - reading.bpm) / (high - low) * inner_height
points.append({"x": round(x, 1), "y": round(y, 1), "reading": reading})
return {
"points": points,
"polyline": " ".join(f"{p['x']},{p['y']}" for p in points),
"low": round(low),
"high": round(high),
"average": round(sum(bpms) / len(bpms), 1),
"trend": pulse_trend(readings),
"width": CHART_WIDTH,
"height": CHART_HEIGHT,
"first_date": first_day,
"last_date": readings[-1].date,
}
@staff_member_required @staff_member_required
def today(request): def app(request):
"""The main screen: tick off the day's doses and record the pulse.""" """The single-page app shell: Vue routes between Today and Recap client-side.
day = parse_date(request.GET.get("date", "")) or timezone.localdate()
if request.method == "POST": All data flows through the API; the shell only carries the per-page-load
day = parse_date(request.POST.get("date", "")) or timezone.localdate() facts the frontend cannot fetch (CSRF token) or that feed the reminder
diagnostics.
# The shared note has its own form and save button, so that editing it """
# while looking at a past day cannot touch that day's records. active_doses = ScheduledDose.objects.filter(
if "global_note" in request.POST: is_active=True, medication__is_active=True
note = GlobalNote.load()
note.body = request.POST["global_note"].strip()
note.save(update_fields=["body", "updated_at"])
messages.success(request, "Shared note saved.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
doses = ScheduledDose.objects.filter(is_active=True, medication__is_active=True)
for dose in doses:
taken = f"dose-{dose.id}" in request.POST
log, _ = DoseLog.objects.get_or_create(
scheduled_dose=dose, date=day, defaults={"taken": taken}
)
log.taken = taken
log.save()
bpm = (request.POST.get("bpm") or "").strip()
if bpm:
try:
PulseReading.objects.update_or_create(
date=day, defaults={"bpm": int(bpm)}
)
except ValueError:
messages.error(request, f"'{bpm}' is not a valid pulse.")
else:
# Clearing the field removes the reading for that day.
PulseReading.objects.filter(date=day).delete()
note_body = (request.POST.get("note") or "").strip()
if note_body:
DailyNote.objects.update_or_create(date=day, defaults={"body": note_body})
else:
DailyNote.objects.filter(date=day).delete()
messages.success(request, f"Saved for {day:%A %d %B}.")
return redirect(f"{reverse('tracker:today')}?date={day.isoformat()}")
return render(
request,
"tracker/today.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,
).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( return render(
request, request,
"tracker/recap.html", "tracker/app.html",
{ {
"title": "Recap", "config": {
"recap_days": build_recap(14), "vapidPublicKey": settings.VAPID_PUBLIC_KEY,
"chart": build_chart(readings), # get_token also sets the CSRF cookie for the session.
"reading_count": len(readings), "csrfToken": get_token(request),
"daily_notes": DailyNote.objects.all(), "activeDoses": active_doses.count(),
"dosesWithReminders": active_doses.filter(
reminder_time__isnull=False
).count(),
"subscribedDevices": request.user.push_subscriptions.count(),
}
}, },
) )

14
uv.lock
View File

@ -377,6 +377,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
] ]
[[package]]
name = "django-vite"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/fe/4be07f538bbf9bf8bf73b045552ec2a1b4fba67e3176abb30490b643368e/django_vite-3.1.0.tar.gz", hash = "sha256:8b4ffe4a9fa81ff568bfb195e74dde8694aaf13fd4b656ae60bf59cce08b85e8", size = 25434, upload-time = "2025-02-23T15:32:07.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/59/7df4b1077fa41b43b4e542696d75138dbb86a65f44248c607b3e0f1014ce/django_vite-3.1.0-py3-none-any.whl", hash = "sha256:4e46572bd6b1ce70784be129205dc2ffcbc7a3c19fea50bebfb72b327bbde5fc", size = 23579, upload-time = "2025-02-23T15:32:06.603Z" },
]
[[package]] [[package]]
name = "executing" name = "executing"
version = "2.2.1" version = "2.2.1"
@ -635,6 +647,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-ninja" }, { name = "django-ninja" },
{ name = "django-vite" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "pywebpush" }, { name = "pywebpush" },
{ name = "whitenoise" }, { name = "whitenoise" },
@ -651,6 +664,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=5.2" }, { name = "django", specifier = ">=5.2" },
{ name = "django-ninja", specifier = ">=1.4" }, { name = "django-ninja", specifier = ">=1.4" },
{ name = "django-vite", specifier = ">=3.1.0" },
{ name = "gunicorn", specifier = ">=26.0.0" }, { name = "gunicorn", specifier = ">=26.0.0" },
{ name = "pywebpush", specifier = ">=2.3.0" }, { name = "pywebpush", specifier = ">=2.3.0" },
{ name = "whitenoise", specifier = ">=6.12.0" }, { name = "whitenoise", specifier = ">=6.12.0" },