Compare commits

...

3 Commits

Author SHA1 Message Date
37ea5eb926 serve static files and docker 2023-11-07 00:21:12 +01:00
bb6c61b08e more frontend stuff 2023-10-19 21:21:43 +02:00
cb6f86207a refactor & ddisable alarms feature 2023-10-02 19:54:00 +02:00
11 changed files with 271 additions and 121 deletions

View File

@@ -1,3 +1,11 @@
FROM --platform=$BUILDPLATFORM node:20-alpine as webbuild
COPY . /build
WORKDIR /build/frontend
RUN npm ci && npm run build
FROM --platform=$BUILDPLATFORM golang:1.21-alpine as build
ADD . /app
@@ -15,6 +23,7 @@ FROM --platform=$TARGETPLATFORM alpine:latest
WORKDIR /data
RUN apk add --no-cache tzdata ca-certificates
COPY --from=build /app/build/morningalarm /app/morningalarm
COPY --from=webbuild /build/frontend/dist /app/public
EXPOSE 3000

View File

@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"

View File

@@ -8,8 +8,8 @@
let info: Info;
let alarms: Alarm[];
onMount(async () => {
Promise.all([
function fetchdata() {
return Promise.all([
fetch("/api/info")
.then((r) => r.json())
.then((data) => {
@@ -21,6 +21,10 @@
alarms = data;
}),
]);
}
onMount(async () => {
await fetchdata();
});
</script>
@@ -33,7 +37,9 @@
{#if alarms}
{#each alarms as alarm}
<div class="mb-2 pb-2 px-2 w-full border-b-2">
<AlarmComp {alarm} />
<AlarmComp {alarm} on:update={()=>{
fetchdata();
}} />
</div>
{/each}
{/if}

View File

@@ -11,7 +11,8 @@
</script>
<div>
<div class="fixed bottom-8 right-5" >
<!-- TODO: handle position of button on mobile -->
<div class="pt-10" >
{#if closed}
<button class="h-16 p-2 bg-orange-500 rounded-xl cursor-pointer" on:click={()=>{
closed = false;
@@ -19,7 +20,7 @@
<MdAddAlarm />
</button>
{:else}
<div class="w-40 h-10 bg-orange-500 rounded-xl flex">
<div class="w-40 h-10 bg-orange-500 rounded-xl flex justify-center">
<form
on:submit|preventDefault={()=>{
closed = true;
@@ -28,7 +29,8 @@
>
<input
type="text"
class="bg-transparent border-b-2 border-white outline-none w-32 m-auto"
inputmode="text"
class="bg-transparent border-b-2 border-white outline-none w-32 m-auto pt-1"
use:focus on:focusout={()=>{
closed = true;
}}

View File

@@ -3,21 +3,45 @@
import MdRemoveCircle from 'svelte-icons/md/MdRemoveCircle.svelte'
//@ts-ignore
import MdAlarmOff from 'svelte-icons/md/MdAlarmOff.svelte'
import type { Alarm } from "../types";
import { createEventDispatcher } from 'svelte';
export let alarm: Alarm;
const dispatch = createEventDispatcher();
</script>
<div class="flex justify-between items-center w-full">
<!-- <div class="text-xl" >{alarm.name}</div> -->
<div class="text-xl">{alarm.time}</div>
<dir class="h-7 cursor-pointer flex gap-6 text-orange-500" >
<MdAlarmOff />
<MdRemoveCircle />
<dir class="flex gap-5">
<button class="h-7 cursor-pointer" on:click={()=>{
fetch(`/api/alarm/${alarm.name}`, {
method: "PATCH",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
disabled: !alarm.disabled
})
}).then(res => {
if (res.status === 200) {
dispatch('update', alarm);
}
})
}}>
<MdAlarmOff />
</button>
{#if alarm.disabled}
<div class="text-xl">Disabled</div>
{/if}
<button class="h-7 cursor-pointer" on:click={()=>{
// TODO: remove alarm
dispatch('remove', alarm);
}}>
<MdRemoveCircle />
</button>
</dir>
</div>
<style>
</style>

View File

@@ -7,7 +7,6 @@
export let info: Info;
let duration = Duration.parse(info.nextAlarmIn);
let localUTOffset = new Date().getTimezoneOffset() * -1 / 60;
let serverTime = new ServerClock(info.serverTime)
@@ -28,7 +27,9 @@
<p class="text-orange-500" >Local time not in sync with server </p>
{/if }
</div>
{#if info.nextAlarmIn}
<div class="text-lg text-center mt-2">
<div class="h-4 inline-block" ><MdAccessAlarm /></div> <span> in {duration.sprintf("%hh %mm")}</span>
<div class="h-4 inline-block" ><MdAccessAlarm /></div> <span> in {Duration.parse(info.nextAlarmIn).sprintf("%hh %mm")}</span>
</div>
{/if}
</div>

View File

@@ -1,8 +1,8 @@
export interface Info {
alarms: number
deviceId: string
nextAlarmAt: string
nextAlarmIn: string
nextAlarmAt?: string
nextAlarmIn?: string
serverTime: string
spotifyUser: string
timezone: string
@@ -13,4 +13,5 @@ export interface Info {
export interface Alarm {
name: string
time: string
disabled: boolean
}

View File

@@ -1,14 +1,12 @@
package morningalarm
import (
"encoding/json"
"errors"
"os"
"time"
"github.com/robfig/cron/v3"
)
// nextAlarm returns the time of the next alarm to fire from the perspective of cron
func (ma *MorningAlarm) nextAlarm() *time.Time {
if len(ma.cr.Entries()) == 0 {
return nil
@@ -25,102 +23,17 @@ func (ma *MorningAlarm) nextAlarm() *time.Time {
return &min
}
func (ma *MorningAlarm) addAlarm(spec string, name string) (cron.EntryID, error) {
// Check if alarm already exists
if ma.getAlarm(name) != nil {
return 0, errors.New("Alarm already exists")
}
id, err := ma.cr.AddFunc(spec, ma.fireAlarm)
if err != nil {
return 0, err
}
ma.alarms = append(ma.alarms, alarm{
id: id,
Time: spec,
Name: name,
})
return id, nil
// armAlarm arms an alarm to fire at the specified time
func (ma *MorningAlarm) armAlarm(alarm *alarm) (cron.EntryID, error) {
return ma.cr.AddFunc(alarm.Time, ma.fireAlarm)
}
func (ma *MorningAlarm) saveAlarms() error {
content, err := json.Marshal(ma.alarms)
if err != nil {
return err
}
err = os.WriteFile("alarms.json", content, 0644)
if err != nil {
return err
}
return nil
}
func (ma *MorningAlarm) loadAlarms() error {
if _, err := os.Stat("alarms.json"); os.IsNotExist(err) {
ma.alarms = []alarm{}
return nil
}
content, err := os.ReadFile("alarms.json")
if err != nil {
return err
}
var ent []alarm
err = json.Unmarshal(content, &ent)
if err != nil {
return err
}
for _, entry := range ent {
_, err := ma.addAlarm(entry.Time, entry.Name)
if err != nil {
return err
}
}
return nil
// disarmAlarm disarms an alarm and prevents it from firing at the specified time
func (ma *MorningAlarm) disarmAlarm(alarm *alarm) {
ma.cr.Remove(alarm.cronID)
}
// fireAlarm function that is called when an alarm is fired
func (ma *MorningAlarm) fireAlarm() {
ma.playWakeUpMusic()
}
func (ma *MorningAlarm) getAlarm(name string) *alarm {
for _, alarm := range ma.alarms {
if alarm.Name == name {
return &alarm
}
}
return nil
}
func (ma *MorningAlarm) deleteAlarm(name string) bool {
for _, alarm := range ma.alarms {
if alarm.Name == name {
ma.cr.Remove(alarm.id)
for i, a := range ma.alarms {
if a.Name == name {
ma.alarms = append(ma.alarms[:i], ma.alarms[i+1:]...)
break
}
}
return true
}
}
return false
}

View File

@@ -7,9 +7,10 @@ import (
)
type alarm struct {
Name string `json:"name" binding:"required"`
Time string `json:"time" binding:"required"`
id cron.EntryID
Name string `json:"name" binding:"required"`
Time string `json:"time" binding:"required"`
Disabled bool `json:"disabled"`
cronID cron.EntryID
}
type MorningAlarmConfig struct {
@@ -36,7 +37,10 @@ func New(config MorningAlarmConfig) *MorningAlarm {
func (ma *MorningAlarm) Start() {
ma.cr = cron.New()
ma.loadAlarms()
ma.loadAlarmsFromFile()
ma.armAllAlarms()
ma.cr.Start()
ma.ro = gin.Default()

138
internal/store.go Normal file
View File

@@ -0,0 +1,138 @@
package morningalarm
import (
"encoding/json"
"errors"
"os"
)
// addAlarm adds an alarm to the list of alarms and starts a cron job to fire it at the specified time
func (ma *MorningAlarm) addAlarm(time, name string) (*alarm, error) {
// Check if alarm already exists
if ma.getAlarm(name) != nil {
return nil, errors.New("Alarm with name " + name + " already exists")
}
alarm := alarm{
Name: name,
Time: time,
Disabled: false,
}
alarm.cronID, _ = ma.armAlarm(&alarm)
ma.alarms = append(ma.alarms, alarm)
return &alarm, nil
}
func (ma *MorningAlarm) getAlarm(name string) *alarm {
for _, alarm := range ma.alarms {
if alarm.Name == name {
return &alarm
}
}
return nil
}
// loadAlarmsFromFile loads the list of alarms from alarms.json into memory
func (ma *MorningAlarm) loadAlarmsFromFile() error {
if _, err := os.Stat("alarms.json"); os.IsNotExist(err) {
ma.alarms = []alarm{}
return nil
}
content, err := os.ReadFile("alarms.json")
if err != nil {
return err
}
err = json.Unmarshal(content, &ma.alarms)
if err != nil {
return err
}
return nil
}
// saveAlarmsToFile saves the list of alarms to alarms.json
func (ma *MorningAlarm) saveAlarmsToFile() error {
content, err := json.Marshal(ma.alarms)
if err != nil {
return err
}
err = os.WriteFile("alarms.json", content, 0644)
if err != nil {
return err
}
return nil
}
// removeAlarm removes an alarm from the list of alarms and stops cron from firing it. Returns true if the alarm was removed, false if it was not found
func (ma *MorningAlarm) removeAlarm(name string) bool {
for _, alarm := range ma.alarms {
if alarm.Name == name {
// Stop cron from firing the alarm
ma.disarmAlarm(&alarm)
// Remove alarm from list of alarms
for i, a := range ma.alarms {
if a.Name == name {
ma.alarms = append(ma.alarms[:i], ma.alarms[i+1:]...)
break
}
}
return true
}
}
return false
}
// armAllAlarms arms all alarms in the list of alarms that are not disabled and returns an error if any of them fail to arm
func (ma *MorningAlarm) armAllAlarms() error {
for _, alarm := range ma.alarms {
if !alarm.Disabled {
id, err := ma.armAlarm(&alarm)
if err != nil {
return err
}
alarm.cronID = id
}
}
return nil
}
// setDisabled sets the disabled status of an alarm
func (ma *MorningAlarm) setDisabled(name string, disabled bool) {
for i, alarm := range ma.alarms {
if alarm.Name == name {
if alarm.Disabled == disabled {
return
}
ma.alarms[i].Disabled = disabled
if disabled {
ma.disarmAlarm(&alarm)
} else {
id, _ := ma.armAlarm(&alarm)
ma.alarms[i].cronID = id
}
return
}
}
}

View File

@@ -2,6 +2,8 @@ package morningalarm
import (
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
@@ -12,7 +14,22 @@ type Device struct {
ID string `json:"id"`
}
type AlarmPatch struct {
Disabled bool `json:"disabled"`
}
func (ma *MorningAlarm) setupWebserver() {
// TODO: This is stupid
execPath, err := os.Executable()
if err != nil {
panic("Shit")
}
// Serve static files
ma.ro.NoRoute(gin.WrapH(http.FileServer(http.Dir(filepath.Join(filepath.Dir(execPath), "public")))))
// Create a new alarm
ma.ro.POST("/api/alarm", func(c *gin.Context) {
var body alarm
@@ -40,7 +57,7 @@ func (ma *MorningAlarm) setupWebserver() {
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} else {
err = ma.saveAlarms()
err = ma.saveAlarmsToFile()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -52,6 +69,7 @@ func (ma *MorningAlarm) setupWebserver() {
}
})
// Get an alarm
ma.ro.GET("/api/alarm/:id", func(c *gin.Context) {
id := c.Param("id")
@@ -65,15 +83,17 @@ func (ma *MorningAlarm) setupWebserver() {
c.JSON(http.StatusOK, alarm)
})
// Get all alarms
ma.ro.GET("/api/alarm", func(c *gin.Context) {
c.JSON(http.StatusOK, ma.alarms)
})
// Delete an alarm
ma.ro.DELETE("/api/alarm/:id", func(c *gin.Context) {
id := c.Param("id")
if ma.deleteAlarm(id) {
err := ma.saveAlarms()
if ma.removeAlarm(id) {
err := ma.saveAlarmsToFile()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -86,6 +106,7 @@ func (ma *MorningAlarm) setupWebserver() {
}
})
// Get info about the server and the alarms
ma.ro.GET("/api/info", func(c *gin.Context) {
spotifyUser, err := ma.sp.CurrentUser(c)
@@ -117,6 +138,7 @@ func (ma *MorningAlarm) setupWebserver() {
c.JSON(http.StatusOK, response)
})
// Get available spotify devices
ma.ro.GET("/api/device", func(c *gin.Context) {
devices, err := ma.getAvailableDevices()
@@ -128,6 +150,7 @@ func (ma *MorningAlarm) setupWebserver() {
c.JSON(http.StatusOK, devices)
})
// Trigger an alarm immediately
ma.ro.POST("/api/trigger", func(c *gin.Context) {
if _, err := ma.playWakeUpMusic(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -136,4 +159,33 @@ func (ma *MorningAlarm) setupWebserver() {
c.JSON(http.StatusOK, gin.H{})
})
ma.ro.PATCH("/api/alarm/:id", func(c *gin.Context) {
id := c.Param("id")
alarm := ma.getAlarm(id)
if alarm == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alarm not found"})
return
}
var body AlarmPatch
if err := c.BindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ma.setDisabled(id, body.Disabled)
err := ma.saveAlarmsToFile()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{})
})
}