Skip to content

Commit

Permalink
feat: self-contained and improved ssi dive log tool (#168)
Browse files Browse the repository at this point in the history
  • Loading branch information
webbertakken authored Apr 14, 2024
1 parent 94dabb6 commit 8bf8540
Show file tree
Hide file tree
Showing 12 changed files with 181 additions and 96 deletions.
6 changes: 6 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ const config: Config = {
],

themeConfig: {
metadata: [
{
name: 'darkreader-lock',
content: 'this site supported dark mode and uses QR codes with white backgrounds',
},
],
navbar: {
title: 'Takken.io',
logo: {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@
"fflate": "^0.8.2",
"js-cookie": "^3.0.5",
"prism-react-renderer": "^2.3.1",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"react-simple-code-editor": "^0.13.1",
"react-three-fiber": "^6.0.13",
Expand Down
14 changes: 14 additions & 0 deletions src/components/QrCode/QrCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react'
import { QRCodeSVG } from 'qrcode.react'

interface QrCodeProps extends React.SVGProps<SVGSVGElement> {
value: string
sizePx?: number
includeMargin?: boolean
}

const QrCode: React.FC<QrCodeProps> = ({ value, sizePx = 400, includeMargin = true }) => {
return <QRCodeSVG value={value} size={sizePx} includeMargin={includeMargin} />
}

export default QrCode
Binary file not shown.
Binary file not shown.
182 changes: 96 additions & 86 deletions src/components/tools/GarminToSsiDiveLogHelper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React, { createRef, useState } from 'react'
import ToolPage from '@theme/ToolPage/ToolPage'
import { useGarminFiles } from '@site/src/domain/diving/garmin/GarminFiles'
import { GarminFiles } from '@site/src/domain/diving/garmin/GarminFiles'
import { GarminDive } from '@site/src/domain/diving/garmin/GarminDive'
import { GarminMessages } from '@site/src/domain/diving/garmin/GarminMessages'
import { SsiDive } from '@site/src/domain/diving/ssi/SsiDive'
import QrCode from '@site/src/components/QrCode/QrCode'
import Image from '@site/src/theme/IdealImage'
import { useNotification } from '@site/src/core/hooks/useNotification'

const interestingMessages = [
'fileIdMesgs',
Expand All @@ -22,21 +25,22 @@ const GarminToSsiDiveLogHelper = (): JSX.Element => {
const [ssiDive, setSsiDive] = useState<Partial<SsiDive> | null>(null)
const [diveQR, setDiveQR] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// const [firstName, setFirstName] = useState<string>()
// const [lastName, setLastName] = useState<string>()
const garminFiles = useGarminFiles()
const notify = useNotification()

const onUploadFile = async (): Promise<void> => {
const fileInput = fileInputRef.current
if (!fileInput?.files) return

// Idea: Could also use `useGarminFiles` to keep adding uploaded files to the UI
const garminFiles = new GarminFiles()
await garminFiles.add(fileInput.files)

const errors = []
for (const dive of garminFiles) {
try {
setMessages(dive.messages)
await parseDive(dive)
notify.success('Dive parsed')
} catch (error) {
errors.push(error)
}
Expand All @@ -53,97 +57,103 @@ const GarminToSsiDiveLogHelper = (): JSX.Element => {
}

return (
<ToolPage title="SSI DiveLog helper">
<ToolPage title="Garmin to SSI DiveLog helper">
<link rel="dns-prefetch" href="https://chart.googleapis.com" />

<div>
<ul>
<li>Upload your garmin .fit file</li>
<li>Scan the resulting QR code in the SSI app</li>
<li>Correct any details and save dive</li>
</ul>

{/*<div>*/}
{/* <label htmlFor="firstName" style={{ display: 'inline-block' }}>*/}
{/* <span style={{ display: 'block' }}>First name:</span>*/}
{/* <input*/}
{/* style={{ padding: 4, margin: '0 4px 4px 0', fontSize: '125%', width: 200 }}*/}
{/* type="text"*/}
{/* id="firstName"*/}
{/* name="firstName"*/}
{/* value={firstName}*/}
{/* onChange={(e) => setFirstName(e.target.value)}*/}
{/* />*/}
{/* </label>*/}
{/* <label htmlFor="lastName" style={{ display: 'inline-block' }}>*/}
{/* <span style={{ display: 'block' }}>Last name:</span>*/}
{/* <input*/}
{/* style={{ padding: 4, margin: '0 4px 4px 0', fontSize: '125%', width: 200 }}*/}
{/* type="text"*/}
{/* id="lastName"*/}
{/* name="lastName"*/}
{/* value={lastName}*/}
{/* onChange={(e) => setLastName(e.target.value)}*/}
{/* />*/}
{/* </label>*/}
{/*</div>*/}

<input
type="file"
ref={fileInputRef}
accept="*.fit"
style={{ display: 'none' }}
onInput={onUploadFile}
/>

<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
padding: '8px 16px',
backgroundColor: 'var(--ifm-color-primary)',
borderRadius: 5,
border: '1px solid var(--ifm-color-primary-light)',
}}
>
Select file
</button>

{error && <p style={{ display: 'inline-block', paddingLeft: 16, color: 'red' }}>{error}</p>}
<input
type="file"
ref={fileInputRef}
accept="*.fit,*.zip"
style={{ display: 'none' }}
onInput={onUploadFile}
/>

<div className="py-4">
<div className="flex gap-4 flex-col-reverse md:flex-row md:items-center">
<div className="flex flex-col items-center">
<ul className="w-full">
<li>
Upload your garmin <code className="text-blue-600 dark:text-blue-400">.fit</code> or{' '}
<code className="text-blue-600 dark:text-blue-400">.zip</code> file
</li>
<li>Scan the resulting QR code in the SSI app</li>
<li>Correct any details and save the dive</li>
<li className="text-green-600 dark:text-green-400">This page does not store data</li>
</ul>

<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer px-4 py-2 bg-[var(--ifm-color-primary)] rounded border-solid border-[var(--ifm-color-primary-light)] border-[1px] w-40 text-white"
>
Select {ssiDive ? 'another ' : ''}file
</button>

{error && (
<p style={{ display: 'inline-block', paddingLeft: 16, color: 'red' }}>{error}</p>
)}
</div>

<div>
<Image
img={require('./assets/exporting-dive-activity-from-garmin-dashboard.webp')}
alt="SSI app showing the QR code scanner"
noPadding
/>
</div>
</div>
</div>
{ssiDive && (
<>
<div>
<br />
<h2>Key information</h2>
<p>Scan the QR code in your SSI app</p>
<p style={{ opacity: 0.5 }}>{diveQR}</p>
{/*<pre>*/}
{/* <code style={{ opacity: 0.5 }}>{JSON.stringify(ssiDive, null, 2)}</code>*/}
{/*</pre>*/}
<img
alt="Dive QR code for scanning in SSI app"
src={`https://chart.googleapis.com/chart?chs=250x250&cht=qr&chl=${diveQR}&cho=UTF-8`}
/>
<div className="py-4">
<h2>Importing your dive</h2>

<div className="flex gap-4 flex-col md:flex-row items-center">
<QrCode value={diveQR} />
<div className="flex flex-col-reverse md:flex-col">
<p>
First click the QR code icon in the app
<span className="hidden md:inline-block">&nbsp;{'->'}</span>
</p>
<p>
<span className="hidden md:inline-block">{'<-'}&nbsp;</span>Then scan this
</p>
</div>
<Image
img={require('./assets/ssi-app-showing-the-qr-code-scanner.webp')}
alt="SSI app showing the QR code scanner"
height={400}
width={184.5}
noPadding
/>
</div>
</div>
</>
)}

{messages && (
<div>
<br />
<h2>Inspect messages</h2>
{Object.keys(messages).map((key) => (
<details key={key}>
<summary
style={{ cursor: 'pointer', opacity: interestingMessages.includes(key) ? 1 : 0.5 }}
>
{key}
</summary>
<code>
<pre>{JSON.stringify(messages[key], null, 2)}</pre>
</code>
</details>
))}
<div className="py-4">
<details>
<summary className="cursor-pointer">
<h2 className="inline-block">Developer data</h2>
</summary>

<p style={{ opacity: 0.5 }}>{diveQR}</p>

{Object.keys(messages).map((key) => (
<details key={key}>
<summary
className="cursor-pointer"
style={{ opacity: interestingMessages.includes(key) ? 1 : 0.5 }}
>
{key}
</summary>
<code>
<pre>{JSON.stringify(messages[key], null, 2)}</pre>
</code>
</details>
))}
</details>
</div>
)}
</ToolPage>
Expand Down
13 changes: 13 additions & 0 deletions src/core/hooks/useNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import toast from 'react-hot-toast'

/**
* Requires <Toaster /> to be included in the root of the app.
*/
export function useNotification() {
return {
success: toast.success,
error: toast.error,
loading: toast.loading,
promise: toast.promise,
}
}
2 changes: 1 addition & 1 deletion src/domain/diving/garmin/GarminFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { unzipSync } from 'fflate'
import { Decoder, Stream } from '@garmin-fit/sdk'
import { GarminDive } from '@site/src/domain/diving/garmin/GarminDive'

class GarminFiles {
export class GarminFiles {
private files: Map<string, Uint8Array> = new Map<string, Uint8Array>();

*[Symbol.iterator](): Generator<GarminDive> {
Expand Down
2 changes: 1 addition & 1 deletion src/domain/diving/ssi/SsiDive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class SsiDive {
watertemp_max_c: garmin.maxTemperature,
// airtemp_c: AirTempCelcius
// vis_m: VisibilityInMeters
deco: 0,
// deco: 0, // Note: Even when set to 0 it will open the deco settings in the SSI app, no deco should be property not present
}
}

Expand Down
10 changes: 6 additions & 4 deletions src/theme/IdealImage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import Modal from '@site/src/components/Modal/Modal'
import cx from 'classnames'
import styles from './index.module.css'

interface IdealImageWrapperProps extends React.ComponentProps<typeof IdealImage> {}
interface IdealImageWrapperProps extends React.ComponentProps<typeof IdealImage> {
noPadding?: boolean
}

const IdealImageWrapper: React.FC<IdealImageWrapperProps> = (props) => {
const IdealImageWrapper: React.FC<IdealImageWrapperProps> = ({ noPadding, ...props }) => {
const [isOpen, setIsOpen] = useState<boolean>(false)

const open = () => setIsOpen(true)
Expand All @@ -27,8 +29,8 @@ const IdealImageWrapper: React.FC<IdealImageWrapperProps> = (props) => {
</Modal>
)}

<div onClick={open} className="cursor-zoom-in">
<IdealImage {...props} className={cx(props.className, 'pb-8')} />
<div onClick={open} className="cursor-zoom-in leading-[0]">
<IdealImage {...props} className={cx(props.className, { 'pb-8': !noPadding })} />
</div>
</>
)
Expand Down
14 changes: 10 additions & 4 deletions src/theme/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import React, { memo, StrictMode } from 'react'
import React, { StrictMode } from 'react'
import { Toaster } from 'react-hot-toast'

interface RootProps {
children: React.ReactNode
}

const Root: React.FC<RootProps> = memo(({ children }) => {
return <StrictMode>{children}</StrictMode>
})
const Root: React.FC<RootProps> = ({ children }) => {
return (
<StrictMode>
<Toaster />
{children}
</StrictMode>
)
}

export default Root
Loading

0 comments on commit 8bf8540

Please sign in to comment.